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
14 changes: 14 additions & 0 deletions .changeset/registry-object-ownership-refusal-envelope.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
---
"@objectstack/objectql": patch
"@objectstack/runtime": patch
---

fix(objectql): `SchemaRegistry.registerObject`'s cross-package ownership refusal carries an ADR-0112 envelope (#14367)

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.

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.

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.

`@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`).
9 changes: 8 additions & 1 deletion packages/objectql/src/metadata-facade.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -200,9 +200,16 @@ describe('MetadataFacade object write/read round-trip', () => {

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

expect((registry as any).metadata.get('object')?.size ?? 0).toBe(0);
expect(((await facade.getObject('task')) as any).label).toBe('Owned');
Expand Down
28 changes: 24 additions & 4 deletions packages/objectql/src/registry-object-overlay-layer.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -75,6 +75,22 @@ const fieldNames = (r: SchemaRegistry, name: string) =>
const kinds = (r: SchemaRegistry, name: string) =>
r.getObjectContributors(name).map((c) => c.ownership);

/**
* What a synchronous registration REFUSED with, or `undefined` when it did not
* refuse. The refusal assertions below read the ADR-0112 envelope off it
* (`code` + `status`), never a bare `toThrow()`: a throw-shaped assertion is
* satisfied by any `Error` from anywhere on the path, which is exactly how an
* ablated sibling check stayed green (#14367).
*/
const refusalOf = (run: () => unknown): (Error & { code?: unknown; status?: unknown }) | undefined => {
try {
run();
return undefined;
} catch (e) {
return e as Error & { code?: unknown; status?: unknown };
}
};

/** The registry as a package boot leaves it, plus the tenant's layer. */
function overlaidRegistry(name = 'myapp_invoice', binding: string = APP_PKG) {
const r = silent();
Expand DownExpand Up@@ -290,8 +306,10 @@ describe('ADR-0029 D9.5 — the single-owner assertion, unchanged, plus one clas

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

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

Expand Down
158 changes: 158 additions & 0 deletions packages/objectql/src/registry-ownership-refusal-envelope.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #14367 — `SchemaRegistry.registerObject`'s cross-package ownership refusal
* carries an ADR-0112 envelope.
*
* ## What this pins, and why an envelope rather than a throw
*
* The refusal (ADR-0029 D3, single owner per object name) used to be a bare
* `Error`. Measured while reverse-verifying the install-time
* `DUPLICATE_ARTIFACT_OBJECT_NAME` check one layer up (#14163): with that
* check ablated, `expect(refused).toBeDefined()` STAYED GREEN, because this
* refusal fired one step later and looked, to a throw-shaped assertion,
* exactly like the check that had just been deleted. Only the envelope
* assertion (`code` + `status`) went red. So every rejection test on this
* path could only be a bare `toThrow()` — precisely the assertion ADR-0112
* and ADR-0130 D3 rule out by name.
*
* Three facts, each its own case so a failure reads as the specific
* regression:
*
* 1. the refusal is `ObjectOwnershipConflictError` with `code` +
* `status: 422` (and the two package ids + the object name as fields);
* 2. the message text is byte-for-byte what the bare `Error` carried —
* the fence that keeps every message-substring assertion and every
* `console.warn` forwarder unchanged;
* 3. the ADR-0029 D9 §6.1 late-install branch beside it (a TENANT-authored
* sitting owner) is NOT a refusal and does not throw this class — or
* anything.
*/

import { describe, it, expect } from 'vitest';
import { ObjectOwnershipConflictError, SchemaRegistry } from './registry.js';

const APP_PKG = 'app.myapp';
const OTHER_PKG = 'app.otherapp';

const packagedBody = (name: string) => ({
name,
label: 'Invoice',
fields: {
name: { name: 'name', type: 'text', label: 'Name' },
packaged_only: { name: 'packaged_only', type: 'text', label: 'Packaged only' },
},
});

const silent = () => {
const r = new SchemaRegistry({ multiTenant: false });
r.logLevel = 'silent';
return r;
};

const kinds = (r: SchemaRegistry, name: string) =>
r.getObjectContributors(name).map((c) => c.ownership);

/** What a synchronous registration REFUSED with, or `undefined` when it did not refuse. */
const refusalOf = (run: () => unknown): unknown => {
try {
run();
return undefined;
} catch (e) {
return e;
}
};

/**
* The text the bare `Error` carried, spelled out in full rather than matched
* by substring: a substring match would stay green through a rewording that
* still contained the fragment, and the whole point of the fence is that the
* forwarders' `console.warn` lines and the existing regex assertions read the
* SAME bytes as before.
*/
const LEGACY_MESSAGE =
'Object "myapp_invoice" is already owned by package "app.myapp". ' +
"Package \"app.otherapp\" cannot claim ownership. Use 'extend' to add fields.";

describe('#14367 — the cross-package ownership refusal is an ADR-0112 envelope', () => {
it('refuses a second code package with `ObjectOwnershipConflictError`: code + status 422, both packages and the object named', () => {
const r = silent();
r.registerObject(packagedBody('myapp_invoice') as any, APP_PKG);

const refused = refusalOf(() => r.registerObject(packagedBody('myapp_invoice') as any, OTHER_PKG));

expect(refused).toBeInstanceOf(ObjectOwnershipConflictError);
// The envelope — never a bare `toThrow()`.
expect(refused).toMatchObject({ code: 'OBJECT_OWNERSHIP_CONFLICT', status: 422 });
const err = refused as ObjectOwnershipConflictError;
expect(err.name).toBe('ObjectOwnershipConflictError');
expect(err.objectName).toBe('myapp_invoice');
expect(err.existingPackageId).toBe(APP_PKG);
expect(err.incomingPackageId).toBe(OTHER_PKG);
// Nothing half-applied: the sitting owner is untouched.
expect(kinds(r, 'myapp_invoice')).toEqual(['own']);
expect(r.getObjectOwner('myapp_invoice')?.packageId).toBe(APP_PKG);
});

it('keeps the message text byte-for-byte — the fence every substring assertion and forwarder relies on', () => {
const r = silent();
r.registerObject(packagedBody('myapp_invoice') as any, APP_PKG);

const refused = refusalOf(() => r.registerObject(packagedBody('myapp_invoice') as any, OTHER_PKG));

expect((refused as Error).message).toBe(LEGACY_MESSAGE);
// …and the existing sites' regex still matches it, which is the same fact
// from the other side.
expect((refused as Error).message).toMatch(/already owned by package "app\.myapp"/);
});

it('the class is constructible on its own with the same envelope and the same text', () => {
// Pinned directly so a change to the constructor's message template is a
// change to THIS line, not only to whatever registry path happens to
// exercise it.
const err = new ObjectOwnershipConflictError('myapp_invoice', APP_PKG, OTHER_PKG);
expect(err).toBeInstanceOf(Error);
expect(err.code).toBe('OBJECT_OWNERSHIP_CONFLICT');
expect(err.status).toBe(422);
expect(err.message).toBe(LEGACY_MESSAGE);
});

/**
* THE FENCE (ADR-0029 D9 §6.1). A code package registering an object a
* TENANT row already holds is a late install, not a refusal: the code layer
* takes ownership and the tenant contribution becomes its overlay. Out of
* scope for the envelope by ruling, and pinned here so the envelope cannot
* creep onto it: the branch throws nothing at all.
*/
it('does NOT refuse the D9 §6.1 late install — a tenant-authored sitting owner is re-classified, nothing is thrown', () => {
const r = silent();
r.registerObject(
{ ...packagedBody('myapp_invoice'), _provenance: 'org' } as any,
'sys_metadata',
);

const refused = refusalOf(() => r.registerObject(packagedBody('myapp_invoice') as any, APP_PKG));

expect(refused).toBeUndefined();
expect(refused).not.toBeInstanceOf(ObjectOwnershipConflictError);
expect(kinds(r, 'myapp_invoice')).toEqual(['own', 'overlay']);
expect(r.getObjectOwner('myapp_invoice')?.packageId).toBe(APP_PKG);
});

/** The remedy the message prescribes is not a claim: `extend` from another package is accepted. */
it("accepts the message's own remedy — an `extend` from the other package is not an ownership claim", () => {
const r = silent();
r.registerObject(packagedBody('myapp_invoice') as any, APP_PKG);

const refused = refusalOf(() =>
r.registerObject(
{ name: 'myapp_invoice', fields: { ext_field: { name: 'ext_field', type: 'text' } } } as any,
OTHER_PKG, undefined, 'extend',
),
);

expect(refused).toBeUndefined();
expect(kinds(r, 'myapp_invoice')).toEqual(['own', 'extend']);
expect(r.getObjectOwner('myapp_invoice')?.packageId).toBe(APP_PKG);
});
});
67 changes: 62 additions & 5 deletions packages/objectql/src/registry.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1271,6 +1271,61 @@ export class ArtifactObjectNameConflictError extends Error {
}
}

/**
* [ADR-0029 D3] The cross-package ownership refusal: a package claims `own`
* on an object name that a DIFFERENT package already owns. Raised by
* {@link SchemaRegistry.registerObject} — the single-owner-per-object-name
* invariant, enforced at the one choke point every registration path goes
* through (the manifest load path via `ObjectQL.registerApp`, the metadata
* bridge, the `sys_metadata` hydration seams). The remedy the message names is
* the supported one: `extend` merges fields into the owner's definition instead
* of claiming a second owner.
*
* Carries the ADR-0112 envelope (`code` + `status`) — the shape this
* repository's rejection tests assert against, never a bare throw. Before it
* carried one, this refusal was a bare `Error`, and a throw-shaped assertion
* on the install-time `DUPLICATE_ARTIFACT_OBJECT_NAME` check one layer up
* stayed green with that check ablated: this refusal fired one step later and
* was indistinguishable to `toThrow()` (#14367). Only an envelope assertion
* can tell the two refusals apart, and only if both carry one.
*
* The message text is byte-for-byte what the bare `Error` carried, so every
* message-substring assertion and every forwarder that interpolates it into a
* `console.warn` or a per-record `errors` count reads exactly what it read
* before. The two package ids are typed as the contributor stores them
* (`ObjectContributor.packageId` is `string | undefined`, #12623) rather than
* narrowed, so the message stays identical on a package-less call too.
*
* ⛔ Not the ADR-0029 D9 §6.1 late-install branch beside it: a TENANT-authored
* sitting owner is re-classified as the code package's overlay layer, and
* nothing is refused there.
*/
export class ObjectOwnershipConflictError extends Error {
readonly code = 'OBJECT_OWNERSHIP_CONFLICT';
readonly status = 422;
/** The fully-qualified object name both packages claim. */
readonly objectName: string;
/** The package that already owns the name. */
readonly existingPackageId: string | undefined;
/** The package whose `own` claim this refusal stopped. */
readonly incomingPackageId: string | undefined;

constructor(
objectName: string,
existingPackageId: string | undefined,
incomingPackageId: string | undefined,
) {
super(
`Object "${objectName}" is already owned by package "${existingPackageId}". ` +
`Package "${incomingPackageId}" cannot claim ownership. Use 'extend' to add fields.`
);
this.name = 'ObjectOwnershipConflictError';
this.objectName = objectName;
this.existingPackageId = existingPackageId;
this.incomingPackageId = incomingPackageId;
}
}

// [#10062] `isTenantAuthored` and `isCodeArtifactBody` used to be defined here.
// They now live in `@objectstack/metadata-core`
// (`code-artifact-provenance.ts`), imported at the top of this file and
Expand DownExpand Up@@ -1681,7 +1736,9 @@ export class SchemaRegistry {
* REPLACES the base at resolution; ADR-0029 D9) | 'extend' (additive merge)
* @param priority - Merge priority (lower applied first, higher wins on conflict)
*
* @throws Error if trying to 'own' an object that already has a PACKAGED owner
* @throws {ObjectOwnershipConflictError} ADR-0112 envelope (`code` +
* `status: 422`) if trying to 'own' an object that already has a PACKAGED
* owner from another package
*/
registerObject(
schema: ServiceObject,
Expand DownExpand Up@@ -1797,10 +1854,10 @@ export class SchemaRegistry {
`the tenant contribution (${existingOwner.packageId}) becomes its overlay layer.`
);
} else if (existingOwner && existingOwner.packageId !== packageId) {
throw new Error(
`Object "${fqn}" is already owned by package "${existingOwner.packageId}". ` +
`Package "${packageId}" cannot claim ownership. Use 'extend' to add fields.`
);
// [ADR-0029 D3] Two packages claiming one name — the cross-package
// refusal, carried as an ADR-0112 envelope (`code` + `status: 422`)
// with the message text unchanged. See {@link ObjectOwnershipConflictError}.
throw new ObjectOwnershipConflictError(fqn, existingOwner.packageId, packageId);
} else if (existingOwner) {
// Remove existing owner contribution from same package (re-registration).
// Normal path (metadata rebuild / HMR / multi-project seed replays the
Expand Down
32 changes: 32 additions & 0 deletions packages/runtime/src/dispatcher-error-vocabulary.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -744,6 +744,38 @@ export const UNREGISTERED_CODE_SITES: readonly UnregisteredCodeSite[] = [
'evidence of a door. If an install door ever answers with this code itself, the verdict becomes ' +
'pending-registration and it belongs in the ledger batch.'
},
{
code: 'OBJECT_OWNERSHIP_CONFLICT',
file: 'packages/objectql/src/registry.ts',
shape: 'classfield',
door: 'none',
verdict: 'boot-refusal',
why:
'ADR-0029 D3 — the refusal for a package claiming `own` on an object name a DIFFERENT package ' +
'already owns, raised by `SchemaRegistry.registerObject` (the ONE spelling of this refusal — ' +
'the ADR-0029 D9 §6.1 late-install branch beside it re-classifies a tenant-authored ' +
'sitting owner and refuses nothing). Measured on this tree, every path to it either aborts boot ' +
'or catches below any door. `ObjectQL.registerApp` (`packages/objectql/src/engine.ts`) lets it ' +
'propagate to `ManifestService.register()` (`packages/objectql/src/plugin.ts`), whose callers ' +
'are the population the three ADR-0130 rows above record: boot-time `manifest.register()` ' +
'inside plugin init (`packages/runtime/src/app-plugin.ts`, the platform app plugins, the ' +
'service plugins), where a throw aborts boot before any HTTP boundary exists; the rehydrate ' +
'loop in `packages/cloud-connection/src/marketplace-install-local-plugin.ts`, which catches per ' +
'entry and logs; and the import route in that same file, which catches and answers with its ' +
'OWN registered `PLUGIN_REGISTER_FAILED` at 422, interpolating this refusal\'s MESSAGE into ' +
'that envelope. Every other caller catches it in-process: `ObjectQL.registerPlugin` ' +
'(`logger.warn`), the `ObjectQLPlugin` metadata bridge\'s reload ingest and `subscribe(\'object\')` ' +
'handler (`logger.warn`), and `metadata-protocol`\'s `applyObjectRegistryMutation` ' +
'(`console.warn`) and `loadMetaFromDb` (the per-record `errors` count). The two HTTP install ' +
'sites — `POST /packages` in `packages/runtime/src/domains/packages.ts` and ' +
'`protocol.installPackage` — call `SchemaRegistry.installPackage`, which records the package ' +
'and never calls `registerObject`, so neither can raise it; `MetadataFacade.register(\'object\')` ' +
'would propagate it, and has no production instantiation. So the code reaches a reader only ' +
'inside a message string, never as `error.code`. Its `status: 422` is the ADR-0112 envelope ' +
'shape this repo\'s rejection tests assert on, not evidence of a door. If a door ever answers ' +
'with this code itself, the verdict becomes pending-registration and it belongs in the ledger ' +
'batch.'
},
// ── [#13233] field-level catalogs, reached by the OBJECT-LITERAL helper ──
//
// The 29 rows below are the whole verdict cost of widening `codehelper` to
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
14 changes: 14 additions & 0 deletions .changeset/registry-object-ownership-refusal-envelope.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
---
"@objectstack/objectql": patch
"@objectstack/runtime": patch
---

fix(objectql): `SchemaRegistry.registerObject`'s cross-package ownership refusal carries an ADR-0112 envelope (#14367)

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.

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.

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.

`@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`).
9 changes: 8 additions & 1 deletion packages/objectql/src/metadata-facade.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -200,9 +200,16 @@ describe('MetadataFacade object write/read round-trip', () => {

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

expect((registry as any).metadata.get('object')?.size ?? 0).toBe(0);
expect(((await facade.getObject('task')) as any).label).toBe('Owned');
Expand Down
28 changes: 24 additions & 4 deletions packages/objectql/src/registry-object-overlay-layer.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -75,6 +75,22 @@ const fieldNames = (r: SchemaRegistry, name: string) =>
const kinds = (r: SchemaRegistry, name: string) =>
r.getObjectContributors(name).map((c) => c.ownership);

/**
* What a synchronous registration REFUSED with, or `undefined` when it did not
* refuse. The refusal assertions below read the ADR-0112 envelope off it
* (`code` + `status`), never a bare `toThrow()`: a throw-shaped assertion is
* satisfied by any `Error` from anywhere on the path, which is exactly how an
* ablated sibling check stayed green (#14367).
*/
const refusalOf = (run: () => unknown): (Error & { code?: unknown; status?: unknown }) | undefined => {
try {
run();
return undefined;
} catch (e) {
return e as Error & { code?: unknown; status?: unknown };
}
};

/** The registry as a package boot leaves it, plus the tenant's layer. */
function overlaidRegistry(name = 'myapp_invoice', binding: string = APP_PKG) {
const r = silent();
Expand DownExpand Up@@ -290,8 +306,10 @@ describe('ADR-0029 D9.5 — the single-owner assertion, unchanged, plus one clas

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

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

Expand Down
158 changes: 158 additions & 0 deletions packages/objectql/src/registry-ownership-refusal-envelope.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #14367 — `SchemaRegistry.registerObject`'s cross-package ownership refusal
* carries an ADR-0112 envelope.
*
* ## What this pins, and why an envelope rather than a throw
*
* The refusal (ADR-0029 D3, single owner per object name) used to be a bare
* `Error`. Measured while reverse-verifying the install-time
* `DUPLICATE_ARTIFACT_OBJECT_NAME` check one layer up (#14163): with that
* check ablated, `expect(refused).toBeDefined()` STAYED GREEN, because this
* refusal fired one step later and looked, to a throw-shaped assertion,
* exactly like the check that had just been deleted. Only the envelope
* assertion (`code` + `status`) went red. So every rejection test on this
* path could only be a bare `toThrow()` — precisely the assertion ADR-0112
* and ADR-0130 D3 rule out by name.
*
* Three facts, each its own case so a failure reads as the specific
* regression:
*
* 1. the refusal is `ObjectOwnershipConflictError` with `code` +
* `status: 422` (and the two package ids + the object name as fields);
* 2. the message text is byte-for-byte what the bare `Error` carried —
* the fence that keeps every message-substring assertion and every
* `console.warn` forwarder unchanged;
* 3. the ADR-0029 D9 §6.1 late-install branch beside it (a TENANT-authored
* sitting owner) is NOT a refusal and does not throw this class — or
* anything.
*/

import { describe, it, expect } from 'vitest';
import { ObjectOwnershipConflictError, SchemaRegistry } from './registry.js';

const APP_PKG = 'app.myapp';
const OTHER_PKG = 'app.otherapp';

const packagedBody = (name: string) => ({
name,
label: 'Invoice',
fields: {
name: { name: 'name', type: 'text', label: 'Name' },
packaged_only: { name: 'packaged_only', type: 'text', label: 'Packaged only' },
},
});

const silent = () => {
const r = new SchemaRegistry({ multiTenant: false });
r.logLevel = 'silent';
return r;
};

const kinds = (r: SchemaRegistry, name: string) =>
r.getObjectContributors(name).map((c) => c.ownership);

/** What a synchronous registration REFUSED with, or `undefined` when it did not refuse. */
const refusalOf = (run: () => unknown): unknown => {
try {
run();
return undefined;
} catch (e) {
return e;
}
};

/**
* The text the bare `Error` carried, spelled out in full rather than matched
* by substring: a substring match would stay green through a rewording that
* still contained the fragment, and the whole point of the fence is that the
* forwarders' `console.warn` lines and the existing regex assertions read the
* SAME bytes as before.
*/
const LEGACY_MESSAGE =
'Object "myapp_invoice" is already owned by package "app.myapp". ' +
"Package \"app.otherapp\" cannot claim ownership. Use 'extend' to add fields.";

describe('#14367 — the cross-package ownership refusal is an ADR-0112 envelope', () => {
it('refuses a second code package with `ObjectOwnershipConflictError`: code + status 422, both packages and the object named', () => {
const r = silent();
r.registerObject(packagedBody('myapp_invoice') as any, APP_PKG);

const refused = refusalOf(() => r.registerObject(packagedBody('myapp_invoice') as any, OTHER_PKG));

expect(refused).toBeInstanceOf(ObjectOwnershipConflictError);
// The envelope — never a bare `toThrow()`.
expect(refused).toMatchObject({ code: 'OBJECT_OWNERSHIP_CONFLICT', status: 422 });
const err = refused as ObjectOwnershipConflictError;
expect(err.name).toBe('ObjectOwnershipConflictError');
expect(err.objectName).toBe('myapp_invoice');
expect(err.existingPackageId).toBe(APP_PKG);
expect(err.incomingPackageId).toBe(OTHER_PKG);
// Nothing half-applied: the sitting owner is untouched.
expect(kinds(r, 'myapp_invoice')).toEqual(['own']);
expect(r.getObjectOwner('myapp_invoice')?.packageId).toBe(APP_PKG);
});

it('keeps the message text byte-for-byte — the fence every substring assertion and forwarder relies on', () => {
const r = silent();
r.registerObject(packagedBody('myapp_invoice') as any, APP_PKG);

const refused = refusalOf(() => r.registerObject(packagedBody('myapp_invoice') as any, OTHER_PKG));

expect((refused as Error).message).toBe(LEGACY_MESSAGE);
// …and the existing sites' regex still matches it, which is the same fact
// from the other side.
expect((refused as Error).message).toMatch(/already owned by package "app\.myapp"/);
});

it('the class is constructible on its own with the same envelope and the same text', () => {
// Pinned directly so a change to the constructor's message template is a
// change to THIS line, not only to whatever registry path happens to
// exercise it.
const err = new ObjectOwnershipConflictError('myapp_invoice', APP_PKG, OTHER_PKG);
expect(err).toBeInstanceOf(Error);
expect(err.code).toBe('OBJECT_OWNERSHIP_CONFLICT');
expect(err.status).toBe(422);
expect(err.message).toBe(LEGACY_MESSAGE);
});

/**
* THE FENCE (ADR-0029 D9 §6.1). A code package registering an object a
* TENANT row already holds is a late install, not a refusal: the code layer
* takes ownership and the tenant contribution becomes its overlay. Out of
* scope for the envelope by ruling, and pinned here so the envelope cannot
* creep onto it: the branch throws nothing at all.
*/
it('does NOT refuse the D9 §6.1 late install — a tenant-authored sitting owner is re-classified, nothing is thrown', () => {
const r = silent();
r.registerObject(
{ ...packagedBody('myapp_invoice'), _provenance: 'org' } as any,
'sys_metadata',
);

const refused = refusalOf(() => r.registerObject(packagedBody('myapp_invoice') as any, APP_PKG));

expect(refused).toBeUndefined();
expect(refused).not.toBeInstanceOf(ObjectOwnershipConflictError);
expect(kinds(r, 'myapp_invoice')).toEqual(['own', 'overlay']);
expect(r.getObjectOwner('myapp_invoice')?.packageId).toBe(APP_PKG);
});

/** The remedy the message prescribes is not a claim: `extend` from another package is accepted. */
it("accepts the message's own remedy — an `extend` from the other package is not an ownership claim", () => {
const r = silent();
r.registerObject(packagedBody('myapp_invoice') as any, APP_PKG);

const refused = refusalOf(() =>
r.registerObject(
{ name: 'myapp_invoice', fields: { ext_field: { name: 'ext_field', type: 'text' } } } as any,
OTHER_PKG, undefined, 'extend',
),
);

expect(refused).toBeUndefined();
expect(kinds(r, 'myapp_invoice')).toEqual(['own', 'extend']);
expect(r.getObjectOwner('myapp_invoice')?.packageId).toBe(APP_PKG);
});
});
67 changes: 62 additions & 5 deletions packages/objectql/src/registry.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1271,6 +1271,61 @@ export class ArtifactObjectNameConflictError extends Error {
}
}

/**
* [ADR-0029 D3] The cross-package ownership refusal: a package claims `own`
* on an object name that a DIFFERENT package already owns. Raised by
* {@link SchemaRegistry.registerObject} — the single-owner-per-object-name
* invariant, enforced at the one choke point every registration path goes
* through (the manifest load path via `ObjectQL.registerApp`, the metadata
* bridge, the `sys_metadata` hydration seams). The remedy the message names is
* the supported one: `extend` merges fields into the owner's definition instead
* of claiming a second owner.
*
* Carries the ADR-0112 envelope (`code` + `status`) — the shape this
* repository's rejection tests assert against, never a bare throw. Before it
* carried one, this refusal was a bare `Error`, and a throw-shaped assertion
* on the install-time `DUPLICATE_ARTIFACT_OBJECT_NAME` check one layer up
* stayed green with that check ablated: this refusal fired one step later and
* was indistinguishable to `toThrow()` (#14367). Only an envelope assertion
* can tell the two refusals apart, and only if both carry one.
*
* The message text is byte-for-byte what the bare `Error` carried, so every
* message-substring assertion and every forwarder that interpolates it into a
* `console.warn` or a per-record `errors` count reads exactly what it read
* before. The two package ids are typed as the contributor stores them
* (`ObjectContributor.packageId` is `string | undefined`, #12623) rather than
* narrowed, so the message stays identical on a package-less call too.
*
* ⛔ Not the ADR-0029 D9 §6.1 late-install branch beside it: a TENANT-authored
* sitting owner is re-classified as the code package's overlay layer, and
* nothing is refused there.
*/
export class ObjectOwnershipConflictError extends Error {
readonly code = 'OBJECT_OWNERSHIP_CONFLICT';
readonly status = 422;
/** The fully-qualified object name both packages claim. */
readonly objectName: string;
/** The package that already owns the name. */
readonly existingPackageId: string | undefined;
/** The package whose `own` claim this refusal stopped. */
readonly incomingPackageId: string | undefined;

constructor(
objectName: string,
existingPackageId: string | undefined,
incomingPackageId: string | undefined,
) {
super(
`Object "${objectName}" is already owned by package "${existingPackageId}". ` +
`Package "${incomingPackageId}" cannot claim ownership. Use 'extend' to add fields.`
);
this.name = 'ObjectOwnershipConflictError';
this.objectName = objectName;
this.existingPackageId = existingPackageId;
this.incomingPackageId = incomingPackageId;
}
}

// [#10062] `isTenantAuthored` and `isCodeArtifactBody` used to be defined here.
// They now live in `@objectstack/metadata-core`
// (`code-artifact-provenance.ts`), imported at the top of this file and
Expand DownExpand Up@@ -1681,7 +1736,9 @@ export class SchemaRegistry {
* REPLACES the base at resolution; ADR-0029 D9) | 'extend' (additive merge)
* @param priority - Merge priority (lower applied first, higher wins on conflict)
*
* @throws Error if trying to 'own' an object that already has a PACKAGED owner
* @throws {ObjectOwnershipConflictError} ADR-0112 envelope (`code` +
* `status: 422`) if trying to 'own' an object that already has a PACKAGED
* owner from another package
*/
registerObject(
schema: ServiceObject,
Expand DownExpand Up@@ -1797,10 +1854,10 @@ export class SchemaRegistry {
`the tenant contribution (${existingOwner.packageId}) becomes its overlay layer.`
);
} else if (existingOwner && existingOwner.packageId !== packageId) {
throw new Error(
`Object "${fqn}" is already owned by package "${existingOwner.packageId}". ` +
`Package "${packageId}" cannot claim ownership. Use 'extend' to add fields.`
);
// [ADR-0029 D3] Two packages claiming one name — the cross-package
// refusal, carried as an ADR-0112 envelope (`code` + `status: 422`)
// with the message text unchanged. See {@link ObjectOwnershipConflictError}.
throw new ObjectOwnershipConflictError(fqn, existingOwner.packageId, packageId);
} else if (existingOwner) {
// Remove existing owner contribution from same package (re-registration).
// Normal path (metadata rebuild / HMR / multi-project seed replays the
Expand Down
32 changes: 32 additions & 0 deletions packages/runtime/src/dispatcher-error-vocabulary.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -744,6 +744,38 @@ export const UNREGISTERED_CODE_SITES: readonly UnregisteredCodeSite[] = [
'evidence of a door. If an install door ever answers with this code itself, the verdict becomes ' +
'pending-registration and it belongs in the ledger batch.'
},
{
code: 'OBJECT_OWNERSHIP_CONFLICT',
file: 'packages/objectql/src/registry.ts',
shape: 'classfield',
door: 'none',
verdict: 'boot-refusal',
why:
'ADR-0029 D3 — the refusal for a package claiming `own` on an object name a DIFFERENT package ' +
'already owns, raised by `SchemaRegistry.registerObject` (the ONE spelling of this refusal — ' +
'the ADR-0029 D9 §6.1 late-install branch beside it re-classifies a tenant-authored ' +
'sitting owner and refuses nothing). Measured on this tree, every path to it either aborts boot ' +
'or catches below any door. `ObjectQL.registerApp` (`packages/objectql/src/engine.ts`) lets it ' +
'propagate to `ManifestService.register()` (`packages/objectql/src/plugin.ts`), whose callers ' +
'are the population the three ADR-0130 rows above record: boot-time `manifest.register()` ' +
'inside plugin init (`packages/runtime/src/app-plugin.ts`, the platform app plugins, the ' +
'service plugins), where a throw aborts boot before any HTTP boundary exists; the rehydrate ' +
'loop in `packages/cloud-connection/src/marketplace-install-local-plugin.ts`, which catches per ' +
'entry and logs; and the import route in that same file, which catches and answers with its ' +
'OWN registered `PLUGIN_REGISTER_FAILED` at 422, interpolating this refusal\'s MESSAGE into ' +
'that envelope. Every other caller catches it in-process: `ObjectQL.registerPlugin` ' +
'(`logger.warn`), the `ObjectQLPlugin` metadata bridge\'s reload ingest and `subscribe(\'object\')` ' +
'handler (`logger.warn`), and `metadata-protocol`\'s `applyObjectRegistryMutation` ' +
'(`console.warn`) and `loadMetaFromDb` (the per-record `errors` count). The two HTTP install ' +
'sites — `POST /packages` in `packages/runtime/src/domains/packages.ts` and ' +
'`protocol.installPackage` — call `SchemaRegistry.installPackage`, which records the package ' +
'and never calls `registerObject`, so neither can raise it; `MetadataFacade.register(\'object\')` ' +
'would propagate it, and has no production instantiation. So the code reaches a reader only ' +
'inside a message string, never as `error.code`. Its `status: 422` is the ADR-0112 envelope ' +
'shape this repo\'s rejection tests assert on, not evidence of a door. If a door ever answers ' +
'with this code itself, the verdict becomes pending-registration and it belongs in the ledger ' +
'batch.'
},
// ── [#13233] field-level catalogs, reached by the OBJECT-LITERAL helper ──
//
// The 29 rows below are the whole verdict cost of widening `codehelper` to
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
14 changes: 14 additions & 0 deletions .changeset/registry-object-ownership-refusal-envelope.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
---
"@objectstack/objectql": patch
"@objectstack/runtime": patch
---

fix(objectql): `SchemaRegistry.registerObject`'s cross-package ownership refusal carries an ADR-0112 envelope (#14367)

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.

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.

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.

`@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`).
9 changes: 8 additions & 1 deletion packages/objectql/src/metadata-facade.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -200,9 +200,16 @@ describe('MetadataFacade object write/read round-trip', () => {

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

expect((registry as any).metadata.get('object')?.size ?? 0).toBe(0);
expect(((await facade.getObject('task')) as any).label).toBe('Owned');
Expand Down
28 changes: 24 additions & 4 deletions packages/objectql/src/registry-object-overlay-layer.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -75,6 +75,22 @@ const fieldNames = (r: SchemaRegistry, name: string) =>
const kinds = (r: SchemaRegistry, name: string) =>
r.getObjectContributors(name).map((c) => c.ownership);

/**
* What a synchronous registration REFUSED with, or `undefined` when it did not
* refuse. The refusal assertions below read the ADR-0112 envelope off it
* (`code` + `status`), never a bare `toThrow()`: a throw-shaped assertion is
* satisfied by any `Error` from anywhere on the path, which is exactly how an
* ablated sibling check stayed green (#14367).
*/
const refusalOf = (run: () => unknown): (Error & { code?: unknown; status?: unknown }) | undefined => {
try {
run();
return undefined;
} catch (e) {
return e as Error & { code?: unknown; status?: unknown };
}
};

/** The registry as a package boot leaves it, plus the tenant's layer. */
function overlaidRegistry(name = 'myapp_invoice', binding: string = APP_PKG) {
const r = silent();
Expand DownExpand Up@@ -290,8 +306,10 @@ describe('ADR-0029 D9.5 — the single-owner assertion, unchanged, plus one clas

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

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

Expand Down
158 changes: 158 additions & 0 deletions packages/objectql/src/registry-ownership-refusal-envelope.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #14367 — `SchemaRegistry.registerObject`'s cross-package ownership refusal
* carries an ADR-0112 envelope.
*
* ## What this pins, and why an envelope rather than a throw
*
* The refusal (ADR-0029 D3, single owner per object name) used to be a bare
* `Error`. Measured while reverse-verifying the install-time
* `DUPLICATE_ARTIFACT_OBJECT_NAME` check one layer up (#14163): with that
* check ablated, `expect(refused).toBeDefined()` STAYED GREEN, because this
* refusal fired one step later and looked, to a throw-shaped assertion,
* exactly like the check that had just been deleted. Only the envelope
* assertion (`code` + `status`) went red. So every rejection test on this
* path could only be a bare `toThrow()` — precisely the assertion ADR-0112
* and ADR-0130 D3 rule out by name.
*
* Three facts, each its own case so a failure reads as the specific
* regression:
*
* 1. the refusal is `ObjectOwnershipConflictError` with `code` +
* `status: 422` (and the two package ids + the object name as fields);
* 2. the message text is byte-for-byte what the bare `Error` carried —
* the fence that keeps every message-substring assertion and every
* `console.warn` forwarder unchanged;
* 3. the ADR-0029 D9 §6.1 late-install branch beside it (a TENANT-authored
* sitting owner) is NOT a refusal and does not throw this class — or
* anything.
*/

import { describe, it, expect } from 'vitest';
import { ObjectOwnershipConflictError, SchemaRegistry } from './registry.js';

const APP_PKG = 'app.myapp';
const OTHER_PKG = 'app.otherapp';

const packagedBody = (name: string) => ({
name,
label: 'Invoice',
fields: {
name: { name: 'name', type: 'text', label: 'Name' },
packaged_only: { name: 'packaged_only', type: 'text', label: 'Packaged only' },
},
});

const silent = () => {
const r = new SchemaRegistry({ multiTenant: false });
r.logLevel = 'silent';
return r;
};

const kinds = (r: SchemaRegistry, name: string) =>
r.getObjectContributors(name).map((c) => c.ownership);

/** What a synchronous registration REFUSED with, or `undefined` when it did not refuse. */
const refusalOf = (run: () => unknown): unknown => {
try {
run();
return undefined;
} catch (e) {
return e;
}
};

/**
* The text the bare `Error` carried, spelled out in full rather than matched
* by substring: a substring match would stay green through a rewording that
* still contained the fragment, and the whole point of the fence is that the
* forwarders' `console.warn` lines and the existing regex assertions read the
* SAME bytes as before.
*/
const LEGACY_MESSAGE =
'Object "myapp_invoice" is already owned by package "app.myapp". ' +
"Package \"app.otherapp\" cannot claim ownership. Use 'extend' to add fields.";

describe('#14367 — the cross-package ownership refusal is an ADR-0112 envelope', () => {
it('refuses a second code package with `ObjectOwnershipConflictError`: code + status 422, both packages and the object named', () => {
const r = silent();
r.registerObject(packagedBody('myapp_invoice') as any, APP_PKG);

const refused = refusalOf(() => r.registerObject(packagedBody('myapp_invoice') as any, OTHER_PKG));

expect(refused).toBeInstanceOf(ObjectOwnershipConflictError);
// The envelope — never a bare `toThrow()`.
expect(refused).toMatchObject({ code: 'OBJECT_OWNERSHIP_CONFLICT', status: 422 });
const err = refused as ObjectOwnershipConflictError;
expect(err.name).toBe('ObjectOwnershipConflictError');
expect(err.objectName).toBe('myapp_invoice');
expect(err.existingPackageId).toBe(APP_PKG);
expect(err.incomingPackageId).toBe(OTHER_PKG);
// Nothing half-applied: the sitting owner is untouched.
expect(kinds(r, 'myapp_invoice')).toEqual(['own']);
expect(r.getObjectOwner('myapp_invoice')?.packageId).toBe(APP_PKG);
});

it('keeps the message text byte-for-byte — the fence every substring assertion and forwarder relies on', () => {
const r = silent();
r.registerObject(packagedBody('myapp_invoice') as any, APP_PKG);

const refused = refusalOf(() => r.registerObject(packagedBody('myapp_invoice') as any, OTHER_PKG));

expect((refused as Error).message).toBe(LEGACY_MESSAGE);
// …and the existing sites' regex still matches it, which is the same fact
// from the other side.
expect((refused as Error).message).toMatch(/already owned by package "app\.myapp"/);
});

it('the class is constructible on its own with the same envelope and the same text', () => {
// Pinned directly so a change to the constructor's message template is a
// change to THIS line, not only to whatever registry path happens to
// exercise it.
const err = new ObjectOwnershipConflictError('myapp_invoice', APP_PKG, OTHER_PKG);
expect(err).toBeInstanceOf(Error);
expect(err.code).toBe('OBJECT_OWNERSHIP_CONFLICT');
expect(err.status).toBe(422);
expect(err.message).toBe(LEGACY_MESSAGE);
});

/**
* THE FENCE (ADR-0029 D9 §6.1). A code package registering an object a
* TENANT row already holds is a late install, not a refusal: the code layer
* takes ownership and the tenant contribution becomes its overlay. Out of
* scope for the envelope by ruling, and pinned here so the envelope cannot
* creep onto it: the branch throws nothing at all.
*/
it('does NOT refuse the D9 §6.1 late install — a tenant-authored sitting owner is re-classified, nothing is thrown', () => {
const r = silent();
r.registerObject(
{ ...packagedBody('myapp_invoice'), _provenance: 'org' } as any,
'sys_metadata',
);

const refused = refusalOf(() => r.registerObject(packagedBody('myapp_invoice') as any, APP_PKG));

expect(refused).toBeUndefined();
expect(refused).not.toBeInstanceOf(ObjectOwnershipConflictError);
expect(kinds(r, 'myapp_invoice')).toEqual(['own', 'overlay']);
expect(r.getObjectOwner('myapp_invoice')?.packageId).toBe(APP_PKG);
});

/** The remedy the message prescribes is not a claim: `extend` from another package is accepted. */
it("accepts the message's own remedy — an `extend` from the other package is not an ownership claim", () => {
const r = silent();
r.registerObject(packagedBody('myapp_invoice') as any, APP_PKG);

const refused = refusalOf(() =>
r.registerObject(
{ name: 'myapp_invoice', fields: { ext_field: { name: 'ext_field', type: 'text' } } } as any,
OTHER_PKG, undefined, 'extend',
),
);

expect(refused).toBeUndefined();
expect(kinds(r, 'myapp_invoice')).toEqual(['own', 'extend']);
expect(r.getObjectOwner('myapp_invoice')?.packageId).toBe(APP_PKG);
});
});
67 changes: 62 additions & 5 deletions packages/objectql/src/registry.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1271,6 +1271,61 @@ export class ArtifactObjectNameConflictError extends Error {
}
}

/**
* [ADR-0029 D3] The cross-package ownership refusal: a package claims `own`
* on an object name that a DIFFERENT package already owns. Raised by
* {@link SchemaRegistry.registerObject} — the single-owner-per-object-name
* invariant, enforced at the one choke point every registration path goes
* through (the manifest load path via `ObjectQL.registerApp`, the metadata
* bridge, the `sys_metadata` hydration seams). The remedy the message names is
* the supported one: `extend` merges fields into the owner's definition instead
* of claiming a second owner.
*
* Carries the ADR-0112 envelope (`code` + `status`) — the shape this
* repository's rejection tests assert against, never a bare throw. Before it
* carried one, this refusal was a bare `Error`, and a throw-shaped assertion
* on the install-time `DUPLICATE_ARTIFACT_OBJECT_NAME` check one layer up
* stayed green with that check ablated: this refusal fired one step later and
* was indistinguishable to `toThrow()` (#14367). Only an envelope assertion
* can tell the two refusals apart, and only if both carry one.
*
* The message text is byte-for-byte what the bare `Error` carried, so every
* message-substring assertion and every forwarder that interpolates it into a
* `console.warn` or a per-record `errors` count reads exactly what it read
* before. The two package ids are typed as the contributor stores them
* (`ObjectContributor.packageId` is `string | undefined`, #12623) rather than
* narrowed, so the message stays identical on a package-less call too.
*
* ⛔ Not the ADR-0029 D9 §6.1 late-install branch beside it: a TENANT-authored
* sitting owner is re-classified as the code package's overlay layer, and
* nothing is refused there.
*/
export class ObjectOwnershipConflictError extends Error {
readonly code = 'OBJECT_OWNERSHIP_CONFLICT';
readonly status = 422;
/** The fully-qualified object name both packages claim. */
readonly objectName: string;
/** The package that already owns the name. */
readonly existingPackageId: string | undefined;
/** The package whose `own` claim this refusal stopped. */
readonly incomingPackageId: string | undefined;

constructor(
objectName: string,
existingPackageId: string | undefined,
incomingPackageId: string | undefined,
) {
super(
`Object "${objectName}" is already owned by package "${existingPackageId}". ` +
`Package "${incomingPackageId}" cannot claim ownership. Use 'extend' to add fields.`
);
this.name = 'ObjectOwnershipConflictError';
this.objectName = objectName;
this.existingPackageId = existingPackageId;
this.incomingPackageId = incomingPackageId;
}
}

// [#10062] `isTenantAuthored` and `isCodeArtifactBody` used to be defined here.
// They now live in `@objectstack/metadata-core`
// (`code-artifact-provenance.ts`), imported at the top of this file and
Expand DownExpand Up@@ -1681,7 +1736,9 @@ export class SchemaRegistry {
* REPLACES the base at resolution; ADR-0029 D9) | 'extend' (additive merge)
* @param priority - Merge priority (lower applied first, higher wins on conflict)
*
* @throws Error if trying to 'own' an object that already has a PACKAGED owner
* @throws {ObjectOwnershipConflictError} ADR-0112 envelope (`code` +
* `status: 422`) if trying to 'own' an object that already has a PACKAGED
* owner from another package
*/
registerObject(
schema: ServiceObject,
Expand DownExpand Up@@ -1797,10 +1854,10 @@ export class SchemaRegistry {
`the tenant contribution (${existingOwner.packageId}) becomes its overlay layer.`
);
} else if (existingOwner && existingOwner.packageId !== packageId) {
throw new Error(
`Object "${fqn}" is already owned by package "${existingOwner.packageId}". ` +
`Package "${packageId}" cannot claim ownership. Use 'extend' to add fields.`
);
// [ADR-0029 D3] Two packages claiming one name — the cross-package
// refusal, carried as an ADR-0112 envelope (`code` + `status: 422`)
// with the message text unchanged. See {@link ObjectOwnershipConflictError}.
throw new ObjectOwnershipConflictError(fqn, existingOwner.packageId, packageId);
} else if (existingOwner) {
// Remove existing owner contribution from same package (re-registration).
// Normal path (metadata rebuild / HMR / multi-project seed replays the
Expand Down
32 changes: 32 additions & 0 deletions packages/runtime/src/dispatcher-error-vocabulary.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -744,6 +744,38 @@ export const UNREGISTERED_CODE_SITES: readonly UnregisteredCodeSite[] = [
'evidence of a door. If an install door ever answers with this code itself, the verdict becomes ' +
'pending-registration and it belongs in the ledger batch.'
},
{
code: 'OBJECT_OWNERSHIP_CONFLICT',
file: 'packages/objectql/src/registry.ts',
shape: 'classfield',
door: 'none',
verdict: 'boot-refusal',
why:
'ADR-0029 D3 — the refusal for a package claiming `own` on an object name a DIFFERENT package ' +
'already owns, raised by `SchemaRegistry.registerObject` (the ONE spelling of this refusal — ' +
'the ADR-0029 D9 §6.1 late-install branch beside it re-classifies a tenant-authored ' +
'sitting owner and refuses nothing). Measured on this tree, every path to it either aborts boot ' +
'or catches below any door. `ObjectQL.registerApp` (`packages/objectql/src/engine.ts`) lets it ' +
'propagate to `ManifestService.register()` (`packages/objectql/src/plugin.ts`), whose callers ' +
'are the population the three ADR-0130 rows above record: boot-time `manifest.register()` ' +
'inside plugin init (`packages/runtime/src/app-plugin.ts`, the platform app plugins, the ' +
'service plugins), where a throw aborts boot before any HTTP boundary exists; the rehydrate ' +
'loop in `packages/cloud-connection/src/marketplace-install-local-plugin.ts`, which catches per ' +
'entry and logs; and the import route in that same file, which catches and answers with its ' +
'OWN registered `PLUGIN_REGISTER_FAILED` at 422, interpolating this refusal\'s MESSAGE into ' +
'that envelope. Every other caller catches it in-process: `ObjectQL.registerPlugin` ' +
'(`logger.warn`), the `ObjectQLPlugin` metadata bridge\'s reload ingest and `subscribe(\'object\')` ' +
'handler (`logger.warn`), and `metadata-protocol`\'s `applyObjectRegistryMutation` ' +
'(`console.warn`) and `loadMetaFromDb` (the per-record `errors` count). The two HTTP install ' +
'sites — `POST /packages` in `packages/runtime/src/domains/packages.ts` and ' +
'`protocol.installPackage` — call `SchemaRegistry.installPackage`, which records the package ' +
'and never calls `registerObject`, so neither can raise it; `MetadataFacade.register(\'object\')` ' +
'would propagate it, and has no production instantiation. So the code reaches a reader only ' +
'inside a message string, never as `error.code`. Its `status: 422` is the ADR-0112 envelope ' +
'shape this repo\'s rejection tests assert on, not evidence of a door. If a door ever answers ' +
'with this code itself, the verdict becomes pending-registration and it belongs in the ledger ' +
'batch.'
},
// ── [#13233] field-level catalogs, reached by the OBJECT-LITERAL helper ──
//
// The 29 rows below are the whole verdict cost of widening `codehelper` to
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
14 changes: 14 additions & 0 deletions .changeset/registry-object-ownership-refusal-envelope.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
---
"@objectstack/objectql": patch
"@objectstack/runtime": patch
---

fix(objectql): `SchemaRegistry.registerObject`'s cross-package ownership refusal carries an ADR-0112 envelope (#14367)

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.

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.

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.

`@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`).
9 changes: 8 additions & 1 deletion packages/objectql/src/metadata-facade.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -200,9 +200,16 @@ describe('MetadataFacade object write/read round-trip', () => {

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

expect((registry as any).metadata.get('object')?.size ?? 0).toBe(0);
expect(((await facade.getObject('task')) as any).label).toBe('Owned');
Expand Down
28 changes: 24 additions & 4 deletions packages/objectql/src/registry-object-overlay-layer.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -75,6 +75,22 @@ const fieldNames = (r: SchemaRegistry, name: string) =>
const kinds = (r: SchemaRegistry, name: string) =>
r.getObjectContributors(name).map((c) => c.ownership);

/**
* What a synchronous registration REFUSED with, or `undefined` when it did not
* refuse. The refusal assertions below read the ADR-0112 envelope off it
* (`code` + `status`), never a bare `toThrow()`: a throw-shaped assertion is
* satisfied by any `Error` from anywhere on the path, which is exactly how an
* ablated sibling check stayed green (#14367).
*/
const refusalOf = (run: () => unknown): (Error & { code?: unknown; status?: unknown }) | undefined => {
try {
run();
return undefined;
} catch (e) {
return e as Error & { code?: unknown; status?: unknown };
}
};

/** The registry as a package boot leaves it, plus the tenant's layer. */
function overlaidRegistry(name = 'myapp_invoice', binding: string = APP_PKG) {
const r = silent();
Expand DownExpand Up@@ -290,8 +306,10 @@ describe('ADR-0029 D9.5 — the single-owner assertion, unchanged, plus one clas

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

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

Expand Down
158 changes: 158 additions & 0 deletions packages/objectql/src/registry-ownership-refusal-envelope.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #14367 — `SchemaRegistry.registerObject`'s cross-package ownership refusal
* carries an ADR-0112 envelope.
*
* ## What this pins, and why an envelope rather than a throw
*
* The refusal (ADR-0029 D3, single owner per object name) used to be a bare
* `Error`. Measured while reverse-verifying the install-time
* `DUPLICATE_ARTIFACT_OBJECT_NAME` check one layer up (#14163): with that
* check ablated, `expect(refused).toBeDefined()` STAYED GREEN, because this
* refusal fired one step later and looked, to a throw-shaped assertion,
* exactly like the check that had just been deleted. Only the envelope
* assertion (`code` + `status`) went red. So every rejection test on this
* path could only be a bare `toThrow()` — precisely the assertion ADR-0112
* and ADR-0130 D3 rule out by name.
*
* Three facts, each its own case so a failure reads as the specific
* regression:
*
* 1. the refusal is `ObjectOwnershipConflictError` with `code` +
* `status: 422` (and the two package ids + the object name as fields);
* 2. the message text is byte-for-byte what the bare `Error` carried —
* the fence that keeps every message-substring assertion and every
* `console.warn` forwarder unchanged;
* 3. the ADR-0029 D9 §6.1 late-install branch beside it (a TENANT-authored
* sitting owner) is NOT a refusal and does not throw this class — or
* anything.
*/

import { describe, it, expect } from 'vitest';
import { ObjectOwnershipConflictError, SchemaRegistry } from './registry.js';

const APP_PKG = 'app.myapp';
const OTHER_PKG = 'app.otherapp';

const packagedBody = (name: string) => ({
name,
label: 'Invoice',
fields: {
name: { name: 'name', type: 'text', label: 'Name' },
packaged_only: { name: 'packaged_only', type: 'text', label: 'Packaged only' },
},
});

const silent = () => {
const r = new SchemaRegistry({ multiTenant: false });
r.logLevel = 'silent';
return r;
};

const kinds = (r: SchemaRegistry, name: string) =>
r.getObjectContributors(name).map((c) => c.ownership);

/** What a synchronous registration REFUSED with, or `undefined` when it did not refuse. */
const refusalOf = (run: () => unknown): unknown => {
try {
run();
return undefined;
} catch (e) {
return e;
}
};

/**
* The text the bare `Error` carried, spelled out in full rather than matched
* by substring: a substring match would stay green through a rewording that
* still contained the fragment, and the whole point of the fence is that the
* forwarders' `console.warn` lines and the existing regex assertions read the
* SAME bytes as before.
*/
const LEGACY_MESSAGE =
'Object "myapp_invoice" is already owned by package "app.myapp". ' +
"Package \"app.otherapp\" cannot claim ownership. Use 'extend' to add fields.";

describe('#14367 — the cross-package ownership refusal is an ADR-0112 envelope', () => {
it('refuses a second code package with `ObjectOwnershipConflictError`: code + status 422, both packages and the object named', () => {
const r = silent();
r.registerObject(packagedBody('myapp_invoice') as any, APP_PKG);

const refused = refusalOf(() => r.registerObject(packagedBody('myapp_invoice') as any, OTHER_PKG));

expect(refused).toBeInstanceOf(ObjectOwnershipConflictError);
// The envelope — never a bare `toThrow()`.
expect(refused).toMatchObject({ code: 'OBJECT_OWNERSHIP_CONFLICT', status: 422 });
const err = refused as ObjectOwnershipConflictError;
expect(err.name).toBe('ObjectOwnershipConflictError');
expect(err.objectName).toBe('myapp_invoice');
expect(err.existingPackageId).toBe(APP_PKG);
expect(err.incomingPackageId).toBe(OTHER_PKG);
// Nothing half-applied: the sitting owner is untouched.
expect(kinds(r, 'myapp_invoice')).toEqual(['own']);
expect(r.getObjectOwner('myapp_invoice')?.packageId).toBe(APP_PKG);
});

it('keeps the message text byte-for-byte — the fence every substring assertion and forwarder relies on', () => {
const r = silent();
r.registerObject(packagedBody('myapp_invoice') as any, APP_PKG);

const refused = refusalOf(() => r.registerObject(packagedBody('myapp_invoice') as any, OTHER_PKG));

expect((refused as Error).message).toBe(LEGACY_MESSAGE);
// …and the existing sites' regex still matches it, which is the same fact
// from the other side.
expect((refused as Error).message).toMatch(/already owned by package "app\.myapp"/);
});

it('the class is constructible on its own with the same envelope and the same text', () => {
// Pinned directly so a change to the constructor's message template is a
// change to THIS line, not only to whatever registry path happens to
// exercise it.
const err = new ObjectOwnershipConflictError('myapp_invoice', APP_PKG, OTHER_PKG);
expect(err).toBeInstanceOf(Error);
expect(err.code).toBe('OBJECT_OWNERSHIP_CONFLICT');
expect(err.status).toBe(422);
expect(err.message).toBe(LEGACY_MESSAGE);
});

/**
* THE FENCE (ADR-0029 D9 §6.1). A code package registering an object a
* TENANT row already holds is a late install, not a refusal: the code layer
* takes ownership and the tenant contribution becomes its overlay. Out of
* scope for the envelope by ruling, and pinned here so the envelope cannot
* creep onto it: the branch throws nothing at all.
*/
it('does NOT refuse the D9 §6.1 late install — a tenant-authored sitting owner is re-classified, nothing is thrown', () => {
const r = silent();
r.registerObject(
{ ...packagedBody('myapp_invoice'), _provenance: 'org' } as any,
'sys_metadata',
);

const refused = refusalOf(() => r.registerObject(packagedBody('myapp_invoice') as any, APP_PKG));

expect(refused).toBeUndefined();
expect(refused).not.toBeInstanceOf(ObjectOwnershipConflictError);
expect(kinds(r, 'myapp_invoice')).toEqual(['own', 'overlay']);
expect(r.getObjectOwner('myapp_invoice')?.packageId).toBe(APP_PKG);
});

/** The remedy the message prescribes is not a claim: `extend` from another package is accepted. */
it("accepts the message's own remedy — an `extend` from the other package is not an ownership claim", () => {
const r = silent();
r.registerObject(packagedBody('myapp_invoice') as any, APP_PKG);

const refused = refusalOf(() =>
r.registerObject(
{ name: 'myapp_invoice', fields: { ext_field: { name: 'ext_field', type: 'text' } } } as any,
OTHER_PKG, undefined, 'extend',
),
);

expect(refused).toBeUndefined();
expect(kinds(r, 'myapp_invoice')).toEqual(['own', 'extend']);
expect(r.getObjectOwner('myapp_invoice')?.packageId).toBe(APP_PKG);
});
});
67 changes: 62 additions & 5 deletions packages/objectql/src/registry.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1271,6 +1271,61 @@ export class ArtifactObjectNameConflictError extends Error {
}
}

/**
* [ADR-0029 D3] The cross-package ownership refusal: a package claims `own`
* on an object name that a DIFFERENT package already owns. Raised by
* {@link SchemaRegistry.registerObject} — the single-owner-per-object-name
* invariant, enforced at the one choke point every registration path goes
* through (the manifest load path via `ObjectQL.registerApp`, the metadata
* bridge, the `sys_metadata` hydration seams). The remedy the message names is
* the supported one: `extend` merges fields into the owner's definition instead
* of claiming a second owner.
*
* Carries the ADR-0112 envelope (`code` + `status`) — the shape this
* repository's rejection tests assert against, never a bare throw. Before it
* carried one, this refusal was a bare `Error`, and a throw-shaped assertion
* on the install-time `DUPLICATE_ARTIFACT_OBJECT_NAME` check one layer up
* stayed green with that check ablated: this refusal fired one step later and
* was indistinguishable to `toThrow()` (#14367). Only an envelope assertion
* can tell the two refusals apart, and only if both carry one.
*
* The message text is byte-for-byte what the bare `Error` carried, so every
* message-substring assertion and every forwarder that interpolates it into a
* `console.warn` or a per-record `errors` count reads exactly what it read
* before. The two package ids are typed as the contributor stores them
* (`ObjectContributor.packageId` is `string | undefined`, #12623) rather than
* narrowed, so the message stays identical on a package-less call too.
*
* ⛔ Not the ADR-0029 D9 §6.1 late-install branch beside it: a TENANT-authored
* sitting owner is re-classified as the code package's overlay layer, and
* nothing is refused there.
*/
export class ObjectOwnershipConflictError extends Error {
readonly code = 'OBJECT_OWNERSHIP_CONFLICT';
readonly status = 422;
/** The fully-qualified object name both packages claim. */
readonly objectName: string;
/** The package that already owns the name. */
readonly existingPackageId: string | undefined;
/** The package whose `own` claim this refusal stopped. */
readonly incomingPackageId: string | undefined;

constructor(
objectName: string,
existingPackageId: string | undefined,
incomingPackageId: string | undefined,
) {
super(
`Object "${objectName}" is already owned by package "${existingPackageId}". ` +
`Package "${incomingPackageId}" cannot claim ownership. Use 'extend' to add fields.`
);
this.name = 'ObjectOwnershipConflictError';
this.objectName = objectName;
this.existingPackageId = existingPackageId;
this.incomingPackageId = incomingPackageId;
}
}

// [#10062] `isTenantAuthored` and `isCodeArtifactBody` used to be defined here.
// They now live in `@objectstack/metadata-core`
// (`code-artifact-provenance.ts`), imported at the top of this file and
Expand DownExpand Up@@ -1681,7 +1736,9 @@ export class SchemaRegistry {
* REPLACES the base at resolution; ADR-0029 D9) | 'extend' (additive merge)
* @param priority - Merge priority (lower applied first, higher wins on conflict)
*
* @throws Error if trying to 'own' an object that already has a PACKAGED owner
* @throws {ObjectOwnershipConflictError} ADR-0112 envelope (`code` +
* `status: 422`) if trying to 'own' an object that already has a PACKAGED
* owner from another package
*/
registerObject(
schema: ServiceObject,
Expand DownExpand Up@@ -1797,10 +1854,10 @@ export class SchemaRegistry {
`the tenant contribution (${existingOwner.packageId}) becomes its overlay layer.`
);
} else if (existingOwner && existingOwner.packageId !== packageId) {
throw new Error(
`Object "${fqn}" is already owned by package "${existingOwner.packageId}". ` +
`Package "${packageId}" cannot claim ownership. Use 'extend' to add fields.`
);
// [ADR-0029 D3] Two packages claiming one name — the cross-package
// refusal, carried as an ADR-0112 envelope (`code` + `status: 422`)
// with the message text unchanged. See {@link ObjectOwnershipConflictError}.
throw new ObjectOwnershipConflictError(fqn, existingOwner.packageId, packageId);
} else if (existingOwner) {
// Remove existing owner contribution from same package (re-registration).
// Normal path (metadata rebuild / HMR / multi-project seed replays the
Expand Down
32 changes: 32 additions & 0 deletions packages/runtime/src/dispatcher-error-vocabulary.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -744,6 +744,38 @@ export const UNREGISTERED_CODE_SITES: readonly UnregisteredCodeSite[] = [
'evidence of a door. If an install door ever answers with this code itself, the verdict becomes ' +
'pending-registration and it belongs in the ledger batch.'
},
{
code: 'OBJECT_OWNERSHIP_CONFLICT',
file: 'packages/objectql/src/registry.ts',
shape: 'classfield',
door: 'none',
verdict: 'boot-refusal',
why:
'ADR-0029 D3 — the refusal for a package claiming `own` on an object name a DIFFERENT package ' +
'already owns, raised by `SchemaRegistry.registerObject` (the ONE spelling of this refusal — ' +
'the ADR-0029 D9 §6.1 late-install branch beside it re-classifies a tenant-authored ' +
'sitting owner and refuses nothing). Measured on this tree, every path to it either aborts boot ' +
'or catches below any door. `ObjectQL.registerApp` (`packages/objectql/src/engine.ts`) lets it ' +
'propagate to `ManifestService.register()` (`packages/objectql/src/plugin.ts`), whose callers ' +
'are the population the three ADR-0130 rows above record: boot-time `manifest.register()` ' +
'inside plugin init (`packages/runtime/src/app-plugin.ts`, the platform app plugins, the ' +
'service plugins), where a throw aborts boot before any HTTP boundary exists; the rehydrate ' +
'loop in `packages/cloud-connection/src/marketplace-install-local-plugin.ts`, which catches per ' +
'entry and logs; and the import route in that same file, which catches and answers with its ' +
'OWN registered `PLUGIN_REGISTER_FAILED` at 422, interpolating this refusal\'s MESSAGE into ' +
'that envelope. Every other caller catches it in-process: `ObjectQL.registerPlugin` ' +
'(`logger.warn`), the `ObjectQLPlugin` metadata bridge\'s reload ingest and `subscribe(\'object\')` ' +
'handler (`logger.warn`), and `metadata-protocol`\'s `applyObjectRegistryMutation` ' +
'(`console.warn`) and `loadMetaFromDb` (the per-record `errors` count). The two HTTP install ' +
'sites — `POST /packages` in `packages/runtime/src/domains/packages.ts` and ' +
'`protocol.installPackage` — call `SchemaRegistry.installPackage`, which records the package ' +
'and never calls `registerObject`, so neither can raise it; `MetadataFacade.register(\'object\')` ' +
'would propagate it, and has no production instantiation. So the code reaches a reader only ' +
'inside a message string, never as `error.code`. Its `status: 422` is the ADR-0112 envelope ' +
'shape this repo\'s rejection tests assert on, not evidence of a door. If a door ever answers ' +
'with this code itself, the verdict becomes pending-registration and it belongs in the ledger ' +
'batch.'
},
// ── [#13233] field-level catalogs, reached by the OBJECT-LITERAL helper ──
//
// The 29 rows below are the whole verdict cost of widening `codehelper` to
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
14 changes: 14 additions & 0 deletions .changeset/registry-object-ownership-refusal-envelope.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
---
"@objectstack/objectql": patch
"@objectstack/runtime": patch
---

fix(objectql): `SchemaRegistry.registerObject`'s cross-package ownership refusal carries an ADR-0112 envelope (#14367)

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.

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.

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.

`@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`).
9 changes: 8 additions & 1 deletion packages/objectql/src/metadata-facade.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -200,9 +200,16 @@ describe('MetadataFacade object write/read round-trip', () => {

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

expect((registry as any).metadata.get('object')?.size ?? 0).toBe(0);
expect(((await facade.getObject('task')) as any).label).toBe('Owned');
Expand Down
28 changes: 24 additions & 4 deletions packages/objectql/src/registry-object-overlay-layer.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -75,6 +75,22 @@ const fieldNames = (r: SchemaRegistry, name: string) =>
const kinds = (r: SchemaRegistry, name: string) =>
r.getObjectContributors(name).map((c) => c.ownership);

/**
* What a synchronous registration REFUSED with, or `undefined` when it did not
* refuse. The refusal assertions below read the ADR-0112 envelope off it
* (`code` + `status`), never a bare `toThrow()`: a throw-shaped assertion is
* satisfied by any `Error` from anywhere on the path, which is exactly how an
* ablated sibling check stayed green (#14367).
*/
const refusalOf = (run: () => unknown): (Error & { code?: unknown; status?: unknown }) | undefined => {
try {
run();
return undefined;
} catch (e) {
return e as Error & { code?: unknown; status?: unknown };
}
};

/** The registry as a package boot leaves it, plus the tenant's layer. */
function overlaidRegistry(name = 'myapp_invoice', binding: string = APP_PKG) {
const r = silent();
Expand DownExpand Up@@ -290,8 +306,10 @@ describe('ADR-0029 D9.5 — the single-owner assertion, unchanged, plus one clas

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

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

Expand Down
158 changes: 158 additions & 0 deletions packages/objectql/src/registry-ownership-refusal-envelope.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #14367 — `SchemaRegistry.registerObject`'s cross-package ownership refusal
* carries an ADR-0112 envelope.
*
* ## What this pins, and why an envelope rather than a throw
*
* The refusal (ADR-0029 D3, single owner per object name) used to be a bare
* `Error`. Measured while reverse-verifying the install-time
* `DUPLICATE_ARTIFACT_OBJECT_NAME` check one layer up (#14163): with that
* check ablated, `expect(refused).toBeDefined()` STAYED GREEN, because this
* refusal fired one step later and looked, to a throw-shaped assertion,
* exactly like the check that had just been deleted. Only the envelope
* assertion (`code` + `status`) went red. So every rejection test on this
* path could only be a bare `toThrow()` — precisely the assertion ADR-0112
* and ADR-0130 D3 rule out by name.
*
* Three facts, each its own case so a failure reads as the specific
* regression:
*
* 1. the refusal is `ObjectOwnershipConflictError` with `code` +
* `status: 422` (and the two package ids + the object name as fields);
* 2. the message text is byte-for-byte what the bare `Error` carried —
* the fence that keeps every message-substring assertion and every
* `console.warn` forwarder unchanged;
* 3. the ADR-0029 D9 §6.1 late-install branch beside it (a TENANT-authored
* sitting owner) is NOT a refusal and does not throw this class — or
* anything.
*/

import { describe, it, expect } from 'vitest';
import { ObjectOwnershipConflictError, SchemaRegistry } from './registry.js';

const APP_PKG = 'app.myapp';
const OTHER_PKG = 'app.otherapp';

const packagedBody = (name: string) => ({
name,
label: 'Invoice',
fields: {
name: { name: 'name', type: 'text', label: 'Name' },
packaged_only: { name: 'packaged_only', type: 'text', label: 'Packaged only' },
},
});

const silent = () => {
const r = new SchemaRegistry({ multiTenant: false });
r.logLevel = 'silent';
return r;
};

const kinds = (r: SchemaRegistry, name: string) =>
r.getObjectContributors(name).map((c) => c.ownership);

/** What a synchronous registration REFUSED with, or `undefined` when it did not refuse. */
const refusalOf = (run: () => unknown): unknown => {
try {
run();
return undefined;
} catch (e) {
return e;
}
};

/**
* The text the bare `Error` carried, spelled out in full rather than matched
* by substring: a substring match would stay green through a rewording that
* still contained the fragment, and the whole point of the fence is that the
* forwarders' `console.warn` lines and the existing regex assertions read the
* SAME bytes as before.
*/
const LEGACY_MESSAGE =
'Object "myapp_invoice" is already owned by package "app.myapp". ' +
"Package \"app.otherapp\" cannot claim ownership. Use 'extend' to add fields.";

describe('#14367 — the cross-package ownership refusal is an ADR-0112 envelope', () => {
it('refuses a second code package with `ObjectOwnershipConflictError`: code + status 422, both packages and the object named', () => {
const r = silent();
r.registerObject(packagedBody('myapp_invoice') as any, APP_PKG);

const refused = refusalOf(() => r.registerObject(packagedBody('myapp_invoice') as any, OTHER_PKG));

expect(refused).toBeInstanceOf(ObjectOwnershipConflictError);
// The envelope — never a bare `toThrow()`.
expect(refused).toMatchObject({ code: 'OBJECT_OWNERSHIP_CONFLICT', status: 422 });
const err = refused as ObjectOwnershipConflictError;
expect(err.name).toBe('ObjectOwnershipConflictError');
expect(err.objectName).toBe('myapp_invoice');
expect(err.existingPackageId).toBe(APP_PKG);
expect(err.incomingPackageId).toBe(OTHER_PKG);
// Nothing half-applied: the sitting owner is untouched.
expect(kinds(r, 'myapp_invoice')).toEqual(['own']);
expect(r.getObjectOwner('myapp_invoice')?.packageId).toBe(APP_PKG);
});

it('keeps the message text byte-for-byte — the fence every substring assertion and forwarder relies on', () => {
const r = silent();
r.registerObject(packagedBody('myapp_invoice') as any, APP_PKG);

const refused = refusalOf(() => r.registerObject(packagedBody('myapp_invoice') as any, OTHER_PKG));

expect((refused as Error).message).toBe(LEGACY_MESSAGE);
// …and the existing sites' regex still matches it, which is the same fact
// from the other side.
expect((refused as Error).message).toMatch(/already owned by package "app\.myapp"/);
});

it('the class is constructible on its own with the same envelope and the same text', () => {
// Pinned directly so a change to the constructor's message template is a
// change to THIS line, not only to whatever registry path happens to
// exercise it.
const err = new ObjectOwnershipConflictError('myapp_invoice', APP_PKG, OTHER_PKG);
expect(err).toBeInstanceOf(Error);
expect(err.code).toBe('OBJECT_OWNERSHIP_CONFLICT');
expect(err.status).toBe(422);
expect(err.message).toBe(LEGACY_MESSAGE);
});

/**
* THE FENCE (ADR-0029 D9 §6.1). A code package registering an object a
* TENANT row already holds is a late install, not a refusal: the code layer
* takes ownership and the tenant contribution becomes its overlay. Out of
* scope for the envelope by ruling, and pinned here so the envelope cannot
* creep onto it: the branch throws nothing at all.
*/
it('does NOT refuse the D9 §6.1 late install — a tenant-authored sitting owner is re-classified, nothing is thrown', () => {
const r = silent();
r.registerObject(
{ ...packagedBody('myapp_invoice'), _provenance: 'org' } as any,
'sys_metadata',
);

const refused = refusalOf(() => r.registerObject(packagedBody('myapp_invoice') as any, APP_PKG));

expect(refused).toBeUndefined();
expect(refused).not.toBeInstanceOf(ObjectOwnershipConflictError);
expect(kinds(r, 'myapp_invoice')).toEqual(['own', 'overlay']);
expect(r.getObjectOwner('myapp_invoice')?.packageId).toBe(APP_PKG);
});

/** The remedy the message prescribes is not a claim: `extend` from another package is accepted. */
it("accepts the message's own remedy — an `extend` from the other package is not an ownership claim", () => {
const r = silent();
r.registerObject(packagedBody('myapp_invoice') as any, APP_PKG);

const refused = refusalOf(() =>
r.registerObject(
{ name: 'myapp_invoice', fields: { ext_field: { name: 'ext_field', type: 'text' } } } as any,
OTHER_PKG, undefined, 'extend',
),
);

expect(refused).toBeUndefined();
expect(kinds(r, 'myapp_invoice')).toEqual(['own', 'extend']);
expect(r.getObjectOwner('myapp_invoice')?.packageId).toBe(APP_PKG);
});
});
67 changes: 62 additions & 5 deletions packages/objectql/src/registry.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1271,6 +1271,61 @@ export class ArtifactObjectNameConflictError extends Error {
}
}

/**
* [ADR-0029 D3] The cross-package ownership refusal: a package claims `own`
* on an object name that a DIFFERENT package already owns. Raised by
* {@link SchemaRegistry.registerObject} — the single-owner-per-object-name
* invariant, enforced at the one choke point every registration path goes
* through (the manifest load path via `ObjectQL.registerApp`, the metadata
* bridge, the `sys_metadata` hydration seams). The remedy the message names is
* the supported one: `extend` merges fields into the owner's definition instead
* of claiming a second owner.
*
* Carries the ADR-0112 envelope (`code` + `status`) — the shape this
* repository's rejection tests assert against, never a bare throw. Before it
* carried one, this refusal was a bare `Error`, and a throw-shaped assertion
* on the install-time `DUPLICATE_ARTIFACT_OBJECT_NAME` check one layer up
* stayed green with that check ablated: this refusal fired one step later and
* was indistinguishable to `toThrow()` (#14367). Only an envelope assertion
* can tell the two refusals apart, and only if both carry one.
*
* The message text is byte-for-byte what the bare `Error` carried, so every
* message-substring assertion and every forwarder that interpolates it into a
* `console.warn` or a per-record `errors` count reads exactly what it read
* before. The two package ids are typed as the contributor stores them
* (`ObjectContributor.packageId` is `string | undefined`, #12623) rather than
* narrowed, so the message stays identical on a package-less call too.
*
* ⛔ Not the ADR-0029 D9 §6.1 late-install branch beside it: a TENANT-authored
* sitting owner is re-classified as the code package's overlay layer, and
* nothing is refused there.
*/
export class ObjectOwnershipConflictError extends Error {
readonly code = 'OBJECT_OWNERSHIP_CONFLICT';
readonly status = 422;
/** The fully-qualified object name both packages claim. */
readonly objectName: string;
/** The package that already owns the name. */
readonly existingPackageId: string | undefined;
/** The package whose `own` claim this refusal stopped. */
readonly incomingPackageId: string | undefined;

constructor(
objectName: string,
existingPackageId: string | undefined,
incomingPackageId: string | undefined,
) {
super(
`Object "${objectName}" is already owned by package "${existingPackageId}". ` +
`Package "${incomingPackageId}" cannot claim ownership. Use 'extend' to add fields.`
);
this.name = 'ObjectOwnershipConflictError';
this.objectName = objectName;
this.existingPackageId = existingPackageId;
this.incomingPackageId = incomingPackageId;
}
}

// [#10062] `isTenantAuthored` and `isCodeArtifactBody` used to be defined here.
// They now live in `@objectstack/metadata-core`
// (`code-artifact-provenance.ts`), imported at the top of this file and
Expand DownExpand Up@@ -1681,7 +1736,9 @@ export class SchemaRegistry {
* REPLACES the base at resolution; ADR-0029 D9) | 'extend' (additive merge)
* @param priority - Merge priority (lower applied first, higher wins on conflict)
*
* @throws Error if trying to 'own' an object that already has a PACKAGED owner
* @throws {ObjectOwnershipConflictError} ADR-0112 envelope (`code` +
* `status: 422`) if trying to 'own' an object that already has a PACKAGED
* owner from another package
*/
registerObject(
schema: ServiceObject,
Expand DownExpand Up@@ -1797,10 +1854,10 @@ export class SchemaRegistry {
`the tenant contribution (${existingOwner.packageId}) becomes its overlay layer.`
);
} else if (existingOwner && existingOwner.packageId !== packageId) {
throw new Error(
`Object "${fqn}" is already owned by package "${existingOwner.packageId}". ` +
`Package "${packageId}" cannot claim ownership. Use 'extend' to add fields.`
);
// [ADR-0029 D3] Two packages claiming one name — the cross-package
// refusal, carried as an ADR-0112 envelope (`code` + `status: 422`)
// with the message text unchanged. See {@link ObjectOwnershipConflictError}.
throw new ObjectOwnershipConflictError(fqn, existingOwner.packageId, packageId);
} else if (existingOwner) {
// Remove existing owner contribution from same package (re-registration).
// Normal path (metadata rebuild / HMR / multi-project seed replays the
Expand Down
32 changes: 32 additions & 0 deletions packages/runtime/src/dispatcher-error-vocabulary.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -744,6 +744,38 @@ export const UNREGISTERED_CODE_SITES: readonly UnregisteredCodeSite[] = [
'evidence of a door. If an install door ever answers with this code itself, the verdict becomes ' +
'pending-registration and it belongs in the ledger batch.'
},
{
code: 'OBJECT_OWNERSHIP_CONFLICT',
file: 'packages/objectql/src/registry.ts',
shape: 'classfield',
door: 'none',
verdict: 'boot-refusal',
why:
'ADR-0029 D3 — the refusal for a package claiming `own` on an object name a DIFFERENT package ' +
'already owns, raised by `SchemaRegistry.registerObject` (the ONE spelling of this refusal — ' +
'the ADR-0029 D9 §6.1 late-install branch beside it re-classifies a tenant-authored ' +
'sitting owner and refuses nothing). Measured on this tree, every path to it either aborts boot ' +
'or catches below any door. `ObjectQL.registerApp` (`packages/objectql/src/engine.ts`) lets it ' +
'propagate to `ManifestService.register()` (`packages/objectql/src/plugin.ts`), whose callers ' +
'are the population the three ADR-0130 rows above record: boot-time `manifest.register()` ' +
'inside plugin init (`packages/runtime/src/app-plugin.ts`, the platform app plugins, the ' +
'service plugins), where a throw aborts boot before any HTTP boundary exists; the rehydrate ' +
'loop in `packages/cloud-connection/src/marketplace-install-local-plugin.ts`, which catches per ' +
'entry and logs; and the import route in that same file, which catches and answers with its ' +
'OWN registered `PLUGIN_REGISTER_FAILED` at 422, interpolating this refusal\'s MESSAGE into ' +
'that envelope. Every other caller catches it in-process: `ObjectQL.registerPlugin` ' +
'(`logger.warn`), the `ObjectQLPlugin` metadata bridge\'s reload ingest and `subscribe(\'object\')` ' +
'handler (`logger.warn`), and `metadata-protocol`\'s `applyObjectRegistryMutation` ' +
'(`console.warn`) and `loadMetaFromDb` (the per-record `errors` count). The two HTTP install ' +
'sites — `POST /packages` in `packages/runtime/src/domains/packages.ts` and ' +
'`protocol.installPackage` — call `SchemaRegistry.installPackage`, which records the package ' +
'and never calls `registerObject`, so neither can raise it; `MetadataFacade.register(\'object\')` ' +
'would propagate it, and has no production instantiation. So the code reaches a reader only ' +
'inside a message string, never as `error.code`. Its `status: 422` is the ADR-0112 envelope ' +
'shape this repo\'s rejection tests assert on, not evidence of a door. If a door ever answers ' +
'with this code itself, the verdict becomes pending-registration and it belongs in the ledger ' +
'batch.'
},
// ── [#13233] field-level catalogs, reached by the OBJECT-LITERAL helper ──
//
// The 29 rows below are the whole verdict cost of widening `codehelper` to
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
14 changes: 14 additions & 0 deletions .changeset/registry-object-ownership-refusal-envelope.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
---
"@objectstack/objectql": patch
"@objectstack/runtime": patch
---

fix(objectql): `SchemaRegistry.registerObject`'s cross-package ownership refusal carries an ADR-0112 envelope (#14367)

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.

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.

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.

`@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`).
9 changes: 8 additions & 1 deletion packages/objectql/src/metadata-facade.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -200,9 +200,16 @@ describe('MetadataFacade object write/read round-trip', () => {

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

expect((registry as any).metadata.get('object')?.size ?? 0).toBe(0);
expect(((await facade.getObject('task')) as any).label).toBe('Owned');
Expand Down
28 changes: 24 additions & 4 deletions packages/objectql/src/registry-object-overlay-layer.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -75,6 +75,22 @@ const fieldNames = (r: SchemaRegistry, name: string) =>
const kinds = (r: SchemaRegistry, name: string) =>
r.getObjectContributors(name).map((c) => c.ownership);

/**
* What a synchronous registration REFUSED with, or `undefined` when it did not
* refuse. The refusal assertions below read the ADR-0112 envelope off it
* (`code` + `status`), never a bare `toThrow()`: a throw-shaped assertion is
* satisfied by any `Error` from anywhere on the path, which is exactly how an
* ablated sibling check stayed green (#14367).
*/
const refusalOf = (run: () => unknown): (Error & { code?: unknown; status?: unknown }) | undefined => {
try {
run();
return undefined;
} catch (e) {
return e as Error & { code?: unknown; status?: unknown };
}
};

/** The registry as a package boot leaves it, plus the tenant's layer. */
function overlaidRegistry(name = 'myapp_invoice', binding: string = APP_PKG) {
const r = silent();
Expand DownExpand Up@@ -290,8 +306,10 @@ describe('ADR-0029 D9.5 — the single-owner assertion, unchanged, plus one clas

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

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

Expand Down
158 changes: 158 additions & 0 deletions packages/objectql/src/registry-ownership-refusal-envelope.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #14367 — `SchemaRegistry.registerObject`'s cross-package ownership refusal
* carries an ADR-0112 envelope.
*
* ## What this pins, and why an envelope rather than a throw
*
* The refusal (ADR-0029 D3, single owner per object name) used to be a bare
* `Error`. Measured while reverse-verifying the install-time
* `DUPLICATE_ARTIFACT_OBJECT_NAME` check one layer up (#14163): with that
* check ablated, `expect(refused).toBeDefined()` STAYED GREEN, because this
* refusal fired one step later and looked, to a throw-shaped assertion,
* exactly like the check that had just been deleted. Only the envelope
* assertion (`code` + `status`) went red. So every rejection test on this
* path could only be a bare `toThrow()` — precisely the assertion ADR-0112
* and ADR-0130 D3 rule out by name.
*
* Three facts, each its own case so a failure reads as the specific
* regression:
*
* 1. the refusal is `ObjectOwnershipConflictError` with `code` +
* `status: 422` (and the two package ids + the object name as fields);
* 2. the message text is byte-for-byte what the bare `Error` carried —
* the fence that keeps every message-substring assertion and every
* `console.warn` forwarder unchanged;
* 3. the ADR-0029 D9 §6.1 late-install branch beside it (a TENANT-authored
* sitting owner) is NOT a refusal and does not throw this class — or
* anything.
*/

import { describe, it, expect } from 'vitest';
import { ObjectOwnershipConflictError, SchemaRegistry } from './registry.js';

const APP_PKG = 'app.myapp';
const OTHER_PKG = 'app.otherapp';

const packagedBody = (name: string) => ({
name,
label: 'Invoice',
fields: {
name: { name: 'name', type: 'text', label: 'Name' },
packaged_only: { name: 'packaged_only', type: 'text', label: 'Packaged only' },
},
});

const silent = () => {
const r = new SchemaRegistry({ multiTenant: false });
r.logLevel = 'silent';
return r;
};

const kinds = (r: SchemaRegistry, name: string) =>
r.getObjectContributors(name).map((c) => c.ownership);

/** What a synchronous registration REFUSED with, or `undefined` when it did not refuse. */
const refusalOf = (run: () => unknown): unknown => {
try {
run();
return undefined;
} catch (e) {
return e;
}
};

/**
* The text the bare `Error` carried, spelled out in full rather than matched
* by substring: a substring match would stay green through a rewording that
* still contained the fragment, and the whole point of the fence is that the
* forwarders' `console.warn` lines and the existing regex assertions read the
* SAME bytes as before.
*/
const LEGACY_MESSAGE =
'Object "myapp_invoice" is already owned by package "app.myapp". ' +
"Package \"app.otherapp\" cannot claim ownership. Use 'extend' to add fields.";

describe('#14367 — the cross-package ownership refusal is an ADR-0112 envelope', () => {
it('refuses a second code package with `ObjectOwnershipConflictError`: code + status 422, both packages and the object named', () => {
const r = silent();
r.registerObject(packagedBody('myapp_invoice') as any, APP_PKG);

const refused = refusalOf(() => r.registerObject(packagedBody('myapp_invoice') as any, OTHER_PKG));

expect(refused).toBeInstanceOf(ObjectOwnershipConflictError);
// The envelope — never a bare `toThrow()`.
expect(refused).toMatchObject({ code: 'OBJECT_OWNERSHIP_CONFLICT', status: 422 });
const err = refused as ObjectOwnershipConflictError;
expect(err.name).toBe('ObjectOwnershipConflictError');
expect(err.objectName).toBe('myapp_invoice');
expect(err.existingPackageId).toBe(APP_PKG);
expect(err.incomingPackageId).toBe(OTHER_PKG);
// Nothing half-applied: the sitting owner is untouched.
expect(kinds(r, 'myapp_invoice')).toEqual(['own']);
expect(r.getObjectOwner('myapp_invoice')?.packageId).toBe(APP_PKG);
});

it('keeps the message text byte-for-byte — the fence every substring assertion and forwarder relies on', () => {
const r = silent();
r.registerObject(packagedBody('myapp_invoice') as any, APP_PKG);

const refused = refusalOf(() => r.registerObject(packagedBody('myapp_invoice') as any, OTHER_PKG));

expect((refused as Error).message).toBe(LEGACY_MESSAGE);
// …and the existing sites' regex still matches it, which is the same fact
// from the other side.
expect((refused as Error).message).toMatch(/already owned by package "app\.myapp"/);
});

it('the class is constructible on its own with the same envelope and the same text', () => {
// Pinned directly so a change to the constructor's message template is a
// change to THIS line, not only to whatever registry path happens to
// exercise it.
const err = new ObjectOwnershipConflictError('myapp_invoice', APP_PKG, OTHER_PKG);
expect(err).toBeInstanceOf(Error);
expect(err.code).toBe('OBJECT_OWNERSHIP_CONFLICT');
expect(err.status).toBe(422);
expect(err.message).toBe(LEGACY_MESSAGE);
});

/**
* THE FENCE (ADR-0029 D9 §6.1). A code package registering an object a
* TENANT row already holds is a late install, not a refusal: the code layer
* takes ownership and the tenant contribution becomes its overlay. Out of
* scope for the envelope by ruling, and pinned here so the envelope cannot
* creep onto it: the branch throws nothing at all.
*/
it('does NOT refuse the D9 §6.1 late install — a tenant-authored sitting owner is re-classified, nothing is thrown', () => {
const r = silent();
r.registerObject(
{ ...packagedBody('myapp_invoice'), _provenance: 'org' } as any,
'sys_metadata',
);

const refused = refusalOf(() => r.registerObject(packagedBody('myapp_invoice') as any, APP_PKG));

expect(refused).toBeUndefined();
expect(refused).not.toBeInstanceOf(ObjectOwnershipConflictError);
expect(kinds(r, 'myapp_invoice')).toEqual(['own', 'overlay']);
expect(r.getObjectOwner('myapp_invoice')?.packageId).toBe(APP_PKG);
});

/** The remedy the message prescribes is not a claim: `extend` from another package is accepted. */
it("accepts the message's own remedy — an `extend` from the other package is not an ownership claim", () => {
const r = silent();
r.registerObject(packagedBody('myapp_invoice') as any, APP_PKG);

const refused = refusalOf(() =>
r.registerObject(
{ name: 'myapp_invoice', fields: { ext_field: { name: 'ext_field', type: 'text' } } } as any,
OTHER_PKG, undefined, 'extend',
),
);

expect(refused).toBeUndefined();
expect(kinds(r, 'myapp_invoice')).toEqual(['own', 'extend']);
expect(r.getObjectOwner('myapp_invoice')?.packageId).toBe(APP_PKG);
});
});
67 changes: 62 additions & 5 deletions packages/objectql/src/registry.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1271,6 +1271,61 @@ export class ArtifactObjectNameConflictError extends Error {
}
}

/**
* [ADR-0029 D3] The cross-package ownership refusal: a package claims `own`
* on an object name that a DIFFERENT package already owns. Raised by
* {@link SchemaRegistry.registerObject} — the single-owner-per-object-name
* invariant, enforced at the one choke point every registration path goes
* through (the manifest load path via `ObjectQL.registerApp`, the metadata
* bridge, the `sys_metadata` hydration seams). The remedy the message names is
* the supported one: `extend` merges fields into the owner's definition instead
* of claiming a second owner.
*
* Carries the ADR-0112 envelope (`code` + `status`) — the shape this
* repository's rejection tests assert against, never a bare throw. Before it
* carried one, this refusal was a bare `Error`, and a throw-shaped assertion
* on the install-time `DUPLICATE_ARTIFACT_OBJECT_NAME` check one layer up
* stayed green with that check ablated: this refusal fired one step later and
* was indistinguishable to `toThrow()` (#14367). Only an envelope assertion
* can tell the two refusals apart, and only if both carry one.
*
* The message text is byte-for-byte what the bare `Error` carried, so every
* message-substring assertion and every forwarder that interpolates it into a
* `console.warn` or a per-record `errors` count reads exactly what it read
* before. The two package ids are typed as the contributor stores them
* (`ObjectContributor.packageId` is `string | undefined`, #12623) rather than
* narrowed, so the message stays identical on a package-less call too.
*
* ⛔ Not the ADR-0029 D9 §6.1 late-install branch beside it: a TENANT-authored
* sitting owner is re-classified as the code package's overlay layer, and
* nothing is refused there.
*/
export class ObjectOwnershipConflictError extends Error {
readonly code = 'OBJECT_OWNERSHIP_CONFLICT';
readonly status = 422;
/** The fully-qualified object name both packages claim. */
readonly objectName: string;
/** The package that already owns the name. */
readonly existingPackageId: string | undefined;
/** The package whose `own` claim this refusal stopped. */
readonly incomingPackageId: string | undefined;

constructor(
objectName: string,
existingPackageId: string | undefined,
incomingPackageId: string | undefined,
) {
super(
`Object "${objectName}" is already owned by package "${existingPackageId}". ` +
`Package "${incomingPackageId}" cannot claim ownership. Use 'extend' to add fields.`
);
this.name = 'ObjectOwnershipConflictError';
this.objectName = objectName;
this.existingPackageId = existingPackageId;
this.incomingPackageId = incomingPackageId;
}
}

// [#10062] `isTenantAuthored` and `isCodeArtifactBody` used to be defined here.
// They now live in `@objectstack/metadata-core`
// (`code-artifact-provenance.ts`), imported at the top of this file and
Expand DownExpand Up@@ -1681,7 +1736,9 @@ export class SchemaRegistry {
* REPLACES the base at resolution; ADR-0029 D9) | 'extend' (additive merge)
* @param priority - Merge priority (lower applied first, higher wins on conflict)
*
* @throws Error if trying to 'own' an object that already has a PACKAGED owner
* @throws {ObjectOwnershipConflictError} ADR-0112 envelope (`code` +
* `status: 422`) if trying to 'own' an object that already has a PACKAGED
* owner from another package
*/
registerObject(
schema: ServiceObject,
Expand DownExpand Up@@ -1797,10 +1854,10 @@ export class SchemaRegistry {
`the tenant contribution (${existingOwner.packageId}) becomes its overlay layer.`
);
} else if (existingOwner && existingOwner.packageId !== packageId) {
throw new Error(
`Object "${fqn}" is already owned by package "${existingOwner.packageId}". ` +
`Package "${packageId}" cannot claim ownership. Use 'extend' to add fields.`
);
// [ADR-0029 D3] Two packages claiming one name — the cross-package
// refusal, carried as an ADR-0112 envelope (`code` + `status: 422`)
// with the message text unchanged. See {@link ObjectOwnershipConflictError}.
throw new ObjectOwnershipConflictError(fqn, existingOwner.packageId, packageId);
} else if (existingOwner) {
// Remove existing owner contribution from same package (re-registration).
// Normal path (metadata rebuild / HMR / multi-project seed replays the
Expand Down
32 changes: 32 additions & 0 deletions packages/runtime/src/dispatcher-error-vocabulary.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -744,6 +744,38 @@ export const UNREGISTERED_CODE_SITES: readonly UnregisteredCodeSite[] = [
'evidence of a door. If an install door ever answers with this code itself, the verdict becomes ' +
'pending-registration and it belongs in the ledger batch.'
},
{
code: 'OBJECT_OWNERSHIP_CONFLICT',
file: 'packages/objectql/src/registry.ts',
shape: 'classfield',
door: 'none',
verdict: 'boot-refusal',
why:
'ADR-0029 D3 — the refusal for a package claiming `own` on an object name a DIFFERENT package ' +
'already owns, raised by `SchemaRegistry.registerObject` (the ONE spelling of this refusal — ' +
'the ADR-0029 D9 §6.1 late-install branch beside it re-classifies a tenant-authored ' +
'sitting owner and refuses nothing). Measured on this tree, every path to it either aborts boot ' +
'or catches below any door. `ObjectQL.registerApp` (`packages/objectql/src/engine.ts`) lets it ' +
'propagate to `ManifestService.register()` (`packages/objectql/src/plugin.ts`), whose callers ' +
'are the population the three ADR-0130 rows above record: boot-time `manifest.register()` ' +
'inside plugin init (`packages/runtime/src/app-plugin.ts`, the platform app plugins, the ' +
'service plugins), where a throw aborts boot before any HTTP boundary exists; the rehydrate ' +
'loop in `packages/cloud-connection/src/marketplace-install-local-plugin.ts`, which catches per ' +
'entry and logs; and the import route in that same file, which catches and answers with its ' +
'OWN registered `PLUGIN_REGISTER_FAILED` at 422, interpolating this refusal\'s MESSAGE into ' +
'that envelope. Every other caller catches it in-process: `ObjectQL.registerPlugin` ' +
'(`logger.warn`), the `ObjectQLPlugin` metadata bridge\'s reload ingest and `subscribe(\'object\')` ' +
'handler (`logger.warn`), and `metadata-protocol`\'s `applyObjectRegistryMutation` ' +
'(`console.warn`) and `loadMetaFromDb` (the per-record `errors` count). The two HTTP install ' +
'sites — `POST /packages` in `packages/runtime/src/domains/packages.ts` and ' +
'`protocol.installPackage` — call `SchemaRegistry.installPackage`, which records the package ' +
'and never calls `registerObject`, so neither can raise it; `MetadataFacade.register(\'object\')` ' +
'would propagate it, and has no production instantiation. So the code reaches a reader only ' +
'inside a message string, never as `error.code`. Its `status: 422` is the ADR-0112 envelope ' +
'shape this repo\'s rejection tests assert on, not evidence of a door. If a door ever answers ' +
'with this code itself, the verdict becomes pending-registration and it belongs in the ledger ' +
'batch.'
},
// ── [#13233] field-level catalogs, reached by the OBJECT-LITERAL helper ──
//
// The 29 rows below are the whole verdict cost of widening `codehelper` to
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
14 changes: 14 additions & 0 deletions .changeset/registry-object-ownership-refusal-envelope.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
---
"@objectstack/objectql": patch
"@objectstack/runtime": patch
---

fix(objectql): `SchemaRegistry.registerObject`'s cross-package ownership refusal carries an ADR-0112 envelope (#14367)

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.

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.

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.

`@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`).
9 changes: 8 additions & 1 deletion packages/objectql/src/metadata-facade.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -200,9 +200,16 @@ describe('MetadataFacade object write/read round-trip', () => {

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

expect((registry as any).metadata.get('object')?.size ?? 0).toBe(0);
expect(((await facade.getObject('task')) as any).label).toBe('Owned');
Expand Down
28 changes: 24 additions & 4 deletions packages/objectql/src/registry-object-overlay-layer.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -75,6 +75,22 @@ const fieldNames = (r: SchemaRegistry, name: string) =>
const kinds = (r: SchemaRegistry, name: string) =>
r.getObjectContributors(name).map((c) => c.ownership);

/**
* What a synchronous registration REFUSED with, or `undefined` when it did not
* refuse. The refusal assertions below read the ADR-0112 envelope off it
* (`code` + `status`), never a bare `toThrow()`: a throw-shaped assertion is
* satisfied by any `Error` from anywhere on the path, which is exactly how an
* ablated sibling check stayed green (#14367).
*/
const refusalOf = (run: () => unknown): (Error & { code?: unknown; status?: unknown }) | undefined => {
try {
run();
return undefined;
} catch (e) {
return e as Error & { code?: unknown; status?: unknown };
}
};

/** The registry as a package boot leaves it, plus the tenant's layer. */
function overlaidRegistry(name = 'myapp_invoice', binding: string = APP_PKG) {
const r = silent();
Expand DownExpand Up@@ -290,8 +306,10 @@ describe('ADR-0029 D9.5 — the single-owner assertion, unchanged, plus one clas

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

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

Expand Down
158 changes: 158 additions & 0 deletions packages/objectql/src/registry-ownership-refusal-envelope.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #14367 — `SchemaRegistry.registerObject`'s cross-package ownership refusal
* carries an ADR-0112 envelope.
*
* ## What this pins, and why an envelope rather than a throw
*
* The refusal (ADR-0029 D3, single owner per object name) used to be a bare
* `Error`. Measured while reverse-verifying the install-time
* `DUPLICATE_ARTIFACT_OBJECT_NAME` check one layer up (#14163): with that
* check ablated, `expect(refused).toBeDefined()` STAYED GREEN, because this
* refusal fired one step later and looked, to a throw-shaped assertion,
* exactly like the check that had just been deleted. Only the envelope
* assertion (`code` + `status`) went red. So every rejection test on this
* path could only be a bare `toThrow()` — precisely the assertion ADR-0112
* and ADR-0130 D3 rule out by name.
*
* Three facts, each its own case so a failure reads as the specific
* regression:
*
* 1. the refusal is `ObjectOwnershipConflictError` with `code` +
* `status: 422` (and the two package ids + the object name as fields);
* 2. the message text is byte-for-byte what the bare `Error` carried —
* the fence that keeps every message-substring assertion and every
* `console.warn` forwarder unchanged;
* 3. the ADR-0029 D9 §6.1 late-install branch beside it (a TENANT-authored
* sitting owner) is NOT a refusal and does not throw this class — or
* anything.
*/

import { describe, it, expect } from 'vitest';
import { ObjectOwnershipConflictError, SchemaRegistry } from './registry.js';

const APP_PKG = 'app.myapp';
const OTHER_PKG = 'app.otherapp';

const packagedBody = (name: string) => ({
name,
label: 'Invoice',
fields: {
name: { name: 'name', type: 'text', label: 'Name' },
packaged_only: { name: 'packaged_only', type: 'text', label: 'Packaged only' },
},
});

const silent = () => {
const r = new SchemaRegistry({ multiTenant: false });
r.logLevel = 'silent';
return r;
};

const kinds = (r: SchemaRegistry, name: string) =>
r.getObjectContributors(name).map((c) => c.ownership);

/** What a synchronous registration REFUSED with, or `undefined` when it did not refuse. */
const refusalOf = (run: () => unknown): unknown => {
try {
run();
return undefined;
} catch (e) {
return e;
}
};

/**
* The text the bare `Error` carried, spelled out in full rather than matched
* by substring: a substring match would stay green through a rewording that
* still contained the fragment, and the whole point of the fence is that the
* forwarders' `console.warn` lines and the existing regex assertions read the
* SAME bytes as before.
*/
const LEGACY_MESSAGE =
'Object "myapp_invoice" is already owned by package "app.myapp". ' +
"Package \"app.otherapp\" cannot claim ownership. Use 'extend' to add fields.";

describe('#14367 — the cross-package ownership refusal is an ADR-0112 envelope', () => {
it('refuses a second code package with `ObjectOwnershipConflictError`: code + status 422, both packages and the object named', () => {
const r = silent();
r.registerObject(packagedBody('myapp_invoice') as any, APP_PKG);

const refused = refusalOf(() => r.registerObject(packagedBody('myapp_invoice') as any, OTHER_PKG));

expect(refused).toBeInstanceOf(ObjectOwnershipConflictError);
// The envelope — never a bare `toThrow()`.
expect(refused).toMatchObject({ code: 'OBJECT_OWNERSHIP_CONFLICT', status: 422 });
const err = refused as ObjectOwnershipConflictError;
expect(err.name).toBe('ObjectOwnershipConflictError');
expect(err.objectName).toBe('myapp_invoice');
expect(err.existingPackageId).toBe(APP_PKG);
expect(err.incomingPackageId).toBe(OTHER_PKG);
// Nothing half-applied: the sitting owner is untouched.
expect(kinds(r, 'myapp_invoice')).toEqual(['own']);
expect(r.getObjectOwner('myapp_invoice')?.packageId).toBe(APP_PKG);
});

it('keeps the message text byte-for-byte — the fence every substring assertion and forwarder relies on', () => {
const r = silent();
r.registerObject(packagedBody('myapp_invoice') as any, APP_PKG);

const refused = refusalOf(() => r.registerObject(packagedBody('myapp_invoice') as any, OTHER_PKG));

expect((refused as Error).message).toBe(LEGACY_MESSAGE);
// …and the existing sites' regex still matches it, which is the same fact
// from the other side.
expect((refused as Error).message).toMatch(/already owned by package "app\.myapp"/);
});

it('the class is constructible on its own with the same envelope and the same text', () => {
// Pinned directly so a change to the constructor's message template is a
// change to THIS line, not only to whatever registry path happens to
// exercise it.
const err = new ObjectOwnershipConflictError('myapp_invoice', APP_PKG, OTHER_PKG);
expect(err).toBeInstanceOf(Error);
expect(err.code).toBe('OBJECT_OWNERSHIP_CONFLICT');
expect(err.status).toBe(422);
expect(err.message).toBe(LEGACY_MESSAGE);
});

/**
* THE FENCE (ADR-0029 D9 §6.1). A code package registering an object a
* TENANT row already holds is a late install, not a refusal: the code layer
* takes ownership and the tenant contribution becomes its overlay. Out of
* scope for the envelope by ruling, and pinned here so the envelope cannot
* creep onto it: the branch throws nothing at all.
*/
it('does NOT refuse the D9 §6.1 late install — a tenant-authored sitting owner is re-classified, nothing is thrown', () => {
const r = silent();
r.registerObject(
{ ...packagedBody('myapp_invoice'), _provenance: 'org' } as any,
'sys_metadata',
);

const refused = refusalOf(() => r.registerObject(packagedBody('myapp_invoice') as any, APP_PKG));

expect(refused).toBeUndefined();
expect(refused).not.toBeInstanceOf(ObjectOwnershipConflictError);
expect(kinds(r, 'myapp_invoice')).toEqual(['own', 'overlay']);
expect(r.getObjectOwner('myapp_invoice')?.packageId).toBe(APP_PKG);
});

/** The remedy the message prescribes is not a claim: `extend` from another package is accepted. */
it("accepts the message's own remedy — an `extend` from the other package is not an ownership claim", () => {
const r = silent();
r.registerObject(packagedBody('myapp_invoice') as any, APP_PKG);

const refused = refusalOf(() =>
r.registerObject(
{ name: 'myapp_invoice', fields: { ext_field: { name: 'ext_field', type: 'text' } } } as any,
OTHER_PKG, undefined, 'extend',
),
);

expect(refused).toBeUndefined();
expect(kinds(r, 'myapp_invoice')).toEqual(['own', 'extend']);
expect(r.getObjectOwner('myapp_invoice')?.packageId).toBe(APP_PKG);
});
});
67 changes: 62 additions & 5 deletions packages/objectql/src/registry.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1271,6 +1271,61 @@ export class ArtifactObjectNameConflictError extends Error {
}
}

/**
* [ADR-0029 D3] The cross-package ownership refusal: a package claims `own`
* on an object name that a DIFFERENT package already owns. Raised by
* {@link SchemaRegistry.registerObject} — the single-owner-per-object-name
* invariant, enforced at the one choke point every registration path goes
* through (the manifest load path via `ObjectQL.registerApp`, the metadata
* bridge, the `sys_metadata` hydration seams). The remedy the message names is
* the supported one: `extend` merges fields into the owner's definition instead
* of claiming a second owner.
*
* Carries the ADR-0112 envelope (`code` + `status`) — the shape this
* repository's rejection tests assert against, never a bare throw. Before it
* carried one, this refusal was a bare `Error`, and a throw-shaped assertion
* on the install-time `DUPLICATE_ARTIFACT_OBJECT_NAME` check one layer up
* stayed green with that check ablated: this refusal fired one step later and
* was indistinguishable to `toThrow()` (#14367). Only an envelope assertion
* can tell the two refusals apart, and only if both carry one.
*
* The message text is byte-for-byte what the bare `Error` carried, so every
* message-substring assertion and every forwarder that interpolates it into a
* `console.warn` or a per-record `errors` count reads exactly what it read
* before. The two package ids are typed as the contributor stores them
* (`ObjectContributor.packageId` is `string | undefined`, #12623) rather than
* narrowed, so the message stays identical on a package-less call too.
*
* ⛔ Not the ADR-0029 D9 §6.1 late-install branch beside it: a TENANT-authored
* sitting owner is re-classified as the code package's overlay layer, and
* nothing is refused there.
*/
export class ObjectOwnershipConflictError extends Error {
readonly code = 'OBJECT_OWNERSHIP_CONFLICT';
readonly status = 422;
/** The fully-qualified object name both packages claim. */
readonly objectName: string;
/** The package that already owns the name. */
readonly existingPackageId: string | undefined;
/** The package whose `own` claim this refusal stopped. */
readonly incomingPackageId: string | undefined;

constructor(
objectName: string,
existingPackageId: string | undefined,
incomingPackageId: string | undefined,
) {
super(
`Object "${objectName}" is already owned by package "${existingPackageId}". ` +
`Package "${incomingPackageId}" cannot claim ownership. Use 'extend' to add fields.`
);
this.name = 'ObjectOwnershipConflictError';
this.objectName = objectName;
this.existingPackageId = existingPackageId;
this.incomingPackageId = incomingPackageId;
}
}

// [#10062] `isTenantAuthored` and `isCodeArtifactBody` used to be defined here.
// They now live in `@objectstack/metadata-core`
// (`code-artifact-provenance.ts`), imported at the top of this file and
Expand DownExpand Up@@ -1681,7 +1736,9 @@ export class SchemaRegistry {
* REPLACES the base at resolution; ADR-0029 D9) | 'extend' (additive merge)
* @param priority - Merge priority (lower applied first, higher wins on conflict)
*
* @throws Error if trying to 'own' an object that already has a PACKAGED owner
* @throws {ObjectOwnershipConflictError} ADR-0112 envelope (`code` +
* `status: 422`) if trying to 'own' an object that already has a PACKAGED
* owner from another package
*/
registerObject(
schema: ServiceObject,
Expand DownExpand Up@@ -1797,10 +1854,10 @@ export class SchemaRegistry {
`the tenant contribution (${existingOwner.packageId}) becomes its overlay layer.`
);
} else if (existingOwner && existingOwner.packageId !== packageId) {
throw new Error(
`Object "${fqn}" is already owned by package "${existingOwner.packageId}". ` +
`Package "${packageId}" cannot claim ownership. Use 'extend' to add fields.`
);
// [ADR-0029 D3] Two packages claiming one name — the cross-package
// refusal, carried as an ADR-0112 envelope (`code` + `status: 422`)
// with the message text unchanged. See {@link ObjectOwnershipConflictError}.
throw new ObjectOwnershipConflictError(fqn, existingOwner.packageId, packageId);
} else if (existingOwner) {
// Remove existing owner contribution from same package (re-registration).
// Normal path (metadata rebuild / HMR / multi-project seed replays the
Expand Down
32 changes: 32 additions & 0 deletions packages/runtime/src/dispatcher-error-vocabulary.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -744,6 +744,38 @@ export const UNREGISTERED_CODE_SITES: readonly UnregisteredCodeSite[] = [
'evidence of a door. If an install door ever answers with this code itself, the verdict becomes ' +
'pending-registration and it belongs in the ledger batch.'
},
{
code: 'OBJECT_OWNERSHIP_CONFLICT',
file: 'packages/objectql/src/registry.ts',
shape: 'classfield',
door: 'none',
verdict: 'boot-refusal',
why:
'ADR-0029 D3 — the refusal for a package claiming `own` on an object name a DIFFERENT package ' +
'already owns, raised by `SchemaRegistry.registerObject` (the ONE spelling of this refusal — ' +
'the ADR-0029 D9 §6.1 late-install branch beside it re-classifies a tenant-authored ' +
'sitting owner and refuses nothing). Measured on this tree, every path to it either aborts boot ' +
'or catches below any door. `ObjectQL.registerApp` (`packages/objectql/src/engine.ts`) lets it ' +
'propagate to `ManifestService.register()` (`packages/objectql/src/plugin.ts`), whose callers ' +
'are the population the three ADR-0130 rows above record: boot-time `manifest.register()` ' +
'inside plugin init (`packages/runtime/src/app-plugin.ts`, the platform app plugins, the ' +
'service plugins), where a throw aborts boot before any HTTP boundary exists; the rehydrate ' +
'loop in `packages/cloud-connection/src/marketplace-install-local-plugin.ts`, which catches per ' +
'entry and logs; and the import route in that same file, which catches and answers with its ' +
'OWN registered `PLUGIN_REGISTER_FAILED` at 422, interpolating this refusal\'s MESSAGE into ' +
'that envelope. Every other caller catches it in-process: `ObjectQL.registerPlugin` ' +
'(`logger.warn`), the `ObjectQLPlugin` metadata bridge\'s reload ingest and `subscribe(\'object\')` ' +
'handler (`logger.warn`), and `metadata-protocol`\'s `applyObjectRegistryMutation` ' +
'(`console.warn`) and `loadMetaFromDb` (the per-record `errors` count). The two HTTP install ' +
'sites — `POST /packages` in `packages/runtime/src/domains/packages.ts` and ' +
'`protocol.installPackage` — call `SchemaRegistry.installPackage`, which records the package ' +
'and never calls `registerObject`, so neither can raise it; `MetadataFacade.register(\'object\')` ' +
'would propagate it, and has no production instantiation. So the code reaches a reader only ' +
'inside a message string, never as `error.code`. Its `status: 422` is the ADR-0112 envelope ' +
'shape this repo\'s rejection tests assert on, not evidence of a door. If a door ever answers ' +
'with this code itself, the verdict becomes pending-registration and it belongs in the ledger ' +
'batch.'
},
// ── [#13233] field-level catalogs, reached by the OBJECT-LITERAL helper ──
//
// The 29 rows below are the whole verdict cost of widening `codehelper` to
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
14 changes: 14 additions & 0 deletions .changeset/registry-object-ownership-refusal-envelope.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
---
"@objectstack/objectql": patch
"@objectstack/runtime": patch
---

fix(objectql): `SchemaRegistry.registerObject`'s cross-package ownership refusal carries an ADR-0112 envelope (#14367)

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.

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.

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.

`@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`).
9 changes: 8 additions & 1 deletion packages/objectql/src/metadata-facade.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -200,9 +200,16 @@ describe('MetadataFacade object write/read round-trip', () => {

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

expect((registry as any).metadata.get('object')?.size ?? 0).toBe(0);
expect(((await facade.getObject('task')) as any).label).toBe('Owned');
Expand Down
28 changes: 24 additions & 4 deletions packages/objectql/src/registry-object-overlay-layer.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -75,6 +75,22 @@ const fieldNames = (r: SchemaRegistry, name: string) =>
const kinds = (r: SchemaRegistry, name: string) =>
r.getObjectContributors(name).map((c) => c.ownership);

/**
* What a synchronous registration REFUSED with, or `undefined` when it did not
* refuse. The refusal assertions below read the ADR-0112 envelope off it
* (`code` + `status`), never a bare `toThrow()`: a throw-shaped assertion is
* satisfied by any `Error` from anywhere on the path, which is exactly how an
* ablated sibling check stayed green (#14367).
*/
const refusalOf = (run: () => unknown): (Error & { code?: unknown; status?: unknown }) | undefined => {
try {
run();
return undefined;
} catch (e) {
return e as Error & { code?: unknown; status?: unknown };
}
};

/** The registry as a package boot leaves it, plus the tenant's layer. */
function overlaidRegistry(name = 'myapp_invoice', binding: string = APP_PKG) {
const r = silent();
Expand DownExpand Up@@ -290,8 +306,10 @@ describe('ADR-0029 D9.5 — the single-owner assertion, unchanged, plus one clas

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

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

Expand Down
158 changes: 158 additions & 0 deletions packages/objectql/src/registry-ownership-refusal-envelope.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #14367 — `SchemaRegistry.registerObject`'s cross-package ownership refusal
* carries an ADR-0112 envelope.
*
* ## What this pins, and why an envelope rather than a throw
*
* The refusal (ADR-0029 D3, single owner per object name) used to be a bare
* `Error`. Measured while reverse-verifying the install-time
* `DUPLICATE_ARTIFACT_OBJECT_NAME` check one layer up (#14163): with that
* check ablated, `expect(refused).toBeDefined()` STAYED GREEN, because this
* refusal fired one step later and looked, to a throw-shaped assertion,
* exactly like the check that had just been deleted. Only the envelope
* assertion (`code` + `status`) went red. So every rejection test on this
* path could only be a bare `toThrow()` — precisely the assertion ADR-0112
* and ADR-0130 D3 rule out by name.
*
* Three facts, each its own case so a failure reads as the specific
* regression:
*
* 1. the refusal is `ObjectOwnershipConflictError` with `code` +
* `status: 422` (and the two package ids + the object name as fields);
* 2. the message text is byte-for-byte what the bare `Error` carried —
* the fence that keeps every message-substring assertion and every
* `console.warn` forwarder unchanged;
* 3. the ADR-0029 D9 §6.1 late-install branch beside it (a TENANT-authored
* sitting owner) is NOT a refusal and does not throw this class — or
* anything.
*/

import { describe, it, expect } from 'vitest';
import { ObjectOwnershipConflictError, SchemaRegistry } from './registry.js';

const APP_PKG = 'app.myapp';
const OTHER_PKG = 'app.otherapp';

const packagedBody = (name: string) => ({
name,
label: 'Invoice',
fields: {
name: { name: 'name', type: 'text', label: 'Name' },
packaged_only: { name: 'packaged_only', type: 'text', label: 'Packaged only' },
},
});

const silent = () => {
const r = new SchemaRegistry({ multiTenant: false });
r.logLevel = 'silent';
return r;
};

const kinds = (r: SchemaRegistry, name: string) =>
r.getObjectContributors(name).map((c) => c.ownership);

/** What a synchronous registration REFUSED with, or `undefined` when it did not refuse. */
const refusalOf = (run: () => unknown): unknown => {
try {
run();
return undefined;
} catch (e) {
return e;
}
};

/**
* The text the bare `Error` carried, spelled out in full rather than matched
* by substring: a substring match would stay green through a rewording that
* still contained the fragment, and the whole point of the fence is that the
* forwarders' `console.warn` lines and the existing regex assertions read the
* SAME bytes as before.
*/
const LEGACY_MESSAGE =
'Object "myapp_invoice" is already owned by package "app.myapp". ' +
"Package \"app.otherapp\" cannot claim ownership. Use 'extend' to add fields.";

describe('#14367 — the cross-package ownership refusal is an ADR-0112 envelope', () => {
it('refuses a second code package with `ObjectOwnershipConflictError`: code + status 422, both packages and the object named', () => {
const r = silent();
r.registerObject(packagedBody('myapp_invoice') as any, APP_PKG);

const refused = refusalOf(() => r.registerObject(packagedBody('myapp_invoice') as any, OTHER_PKG));

expect(refused).toBeInstanceOf(ObjectOwnershipConflictError);
// The envelope — never a bare `toThrow()`.
expect(refused).toMatchObject({ code: 'OBJECT_OWNERSHIP_CONFLICT', status: 422 });
const err = refused as ObjectOwnershipConflictError;
expect(err.name).toBe('ObjectOwnershipConflictError');
expect(err.objectName).toBe('myapp_invoice');
expect(err.existingPackageId).toBe(APP_PKG);
expect(err.incomingPackageId).toBe(OTHER_PKG);
// Nothing half-applied: the sitting owner is untouched.
expect(kinds(r, 'myapp_invoice')).toEqual(['own']);
expect(r.getObjectOwner('myapp_invoice')?.packageId).toBe(APP_PKG);
});

it('keeps the message text byte-for-byte — the fence every substring assertion and forwarder relies on', () => {
const r = silent();
r.registerObject(packagedBody('myapp_invoice') as any, APP_PKG);

const refused = refusalOf(() => r.registerObject(packagedBody('myapp_invoice') as any, OTHER_PKG));

expect((refused as Error).message).toBe(LEGACY_MESSAGE);
// …and the existing sites' regex still matches it, which is the same fact
// from the other side.
expect((refused as Error).message).toMatch(/already owned by package "app\.myapp"/);
});

it('the class is constructible on its own with the same envelope and the same text', () => {
// Pinned directly so a change to the constructor's message template is a
// change to THIS line, not only to whatever registry path happens to
// exercise it.
const err = new ObjectOwnershipConflictError('myapp_invoice', APP_PKG, OTHER_PKG);
expect(err).toBeInstanceOf(Error);
expect(err.code).toBe('OBJECT_OWNERSHIP_CONFLICT');
expect(err.status).toBe(422);
expect(err.message).toBe(LEGACY_MESSAGE);
});

/**
* THE FENCE (ADR-0029 D9 §6.1). A code package registering an object a
* TENANT row already holds is a late install, not a refusal: the code layer
* takes ownership and the tenant contribution becomes its overlay. Out of
* scope for the envelope by ruling, and pinned here so the envelope cannot
* creep onto it: the branch throws nothing at all.
*/
it('does NOT refuse the D9 §6.1 late install — a tenant-authored sitting owner is re-classified, nothing is thrown', () => {
const r = silent();
r.registerObject(
{ ...packagedBody('myapp_invoice'), _provenance: 'org' } as any,
'sys_metadata',
);

const refused = refusalOf(() => r.registerObject(packagedBody('myapp_invoice') as any, APP_PKG));

expect(refused).toBeUndefined();
expect(refused).not.toBeInstanceOf(ObjectOwnershipConflictError);
expect(kinds(r, 'myapp_invoice')).toEqual(['own', 'overlay']);
expect(r.getObjectOwner('myapp_invoice')?.packageId).toBe(APP_PKG);
});

/** The remedy the message prescribes is not a claim: `extend` from another package is accepted. */
it("accepts the message's own remedy — an `extend` from the other package is not an ownership claim", () => {
const r = silent();
r.registerObject(packagedBody('myapp_invoice') as any, APP_PKG);

const refused = refusalOf(() =>
r.registerObject(
{ name: 'myapp_invoice', fields: { ext_field: { name: 'ext_field', type: 'text' } } } as any,
OTHER_PKG, undefined, 'extend',
),
);

expect(refused).toBeUndefined();
expect(kinds(r, 'myapp_invoice')).toEqual(['own', 'extend']);
expect(r.getObjectOwner('myapp_invoice')?.packageId).toBe(APP_PKG);
});
});
67 changes: 62 additions & 5 deletions packages/objectql/src/registry.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1271,6 +1271,61 @@ export class ArtifactObjectNameConflictError extends Error {
}
}

/**
* [ADR-0029 D3] The cross-package ownership refusal: a package claims `own`
* on an object name that a DIFFERENT package already owns. Raised by
* {@link SchemaRegistry.registerObject} — the single-owner-per-object-name
* invariant, enforced at the one choke point every registration path goes
* through (the manifest load path via `ObjectQL.registerApp`, the metadata
* bridge, the `sys_metadata` hydration seams). The remedy the message names is
* the supported one: `extend` merges fields into the owner's definition instead
* of claiming a second owner.
*
* Carries the ADR-0112 envelope (`code` + `status`) — the shape this
* repository's rejection tests assert against, never a bare throw. Before it
* carried one, this refusal was a bare `Error`, and a throw-shaped assertion
* on the install-time `DUPLICATE_ARTIFACT_OBJECT_NAME` check one layer up
* stayed green with that check ablated: this refusal fired one step later and
* was indistinguishable to `toThrow()` (#14367). Only an envelope assertion
* can tell the two refusals apart, and only if both carry one.
*
* The message text is byte-for-byte what the bare `Error` carried, so every
* message-substring assertion and every forwarder that interpolates it into a
* `console.warn` or a per-record `errors` count reads exactly what it read
* before. The two package ids are typed as the contributor stores them
* (`ObjectContributor.packageId` is `string | undefined`, #12623) rather than
* narrowed, so the message stays identical on a package-less call too.
*
* ⛔ Not the ADR-0029 D9 §6.1 late-install branch beside it: a TENANT-authored
* sitting owner is re-classified as the code package's overlay layer, and
* nothing is refused there.
*/
export class ObjectOwnershipConflictError extends Error {
readonly code = 'OBJECT_OWNERSHIP_CONFLICT';
readonly status = 422;
/** The fully-qualified object name both packages claim. */
readonly objectName: string;
/** The package that already owns the name. */
readonly existingPackageId: string | undefined;
/** The package whose `own` claim this refusal stopped. */
readonly incomingPackageId: string | undefined;

constructor(
objectName: string,
existingPackageId: string | undefined,
incomingPackageId: string | undefined,
) {
super(
`Object "${objectName}" is already owned by package "${existingPackageId}". ` +
`Package "${incomingPackageId}" cannot claim ownership. Use 'extend' to add fields.`
);
this.name = 'ObjectOwnershipConflictError';
this.objectName = objectName;
this.existingPackageId = existingPackageId;
this.incomingPackageId = incomingPackageId;
}
}

// [#10062] `isTenantAuthored` and `isCodeArtifactBody` used to be defined here.
// They now live in `@objectstack/metadata-core`
// (`code-artifact-provenance.ts`), imported at the top of this file and
Expand DownExpand Up@@ -1681,7 +1736,9 @@ export class SchemaRegistry {
* REPLACES the base at resolution; ADR-0029 D9) | 'extend' (additive merge)
* @param priority - Merge priority (lower applied first, higher wins on conflict)
*
* @throws Error if trying to 'own' an object that already has a PACKAGED owner
* @throws {ObjectOwnershipConflictError} ADR-0112 envelope (`code` +
* `status: 422`) if trying to 'own' an object that already has a PACKAGED
* owner from another package
*/
registerObject(
schema: ServiceObject,
Expand DownExpand Up@@ -1797,10 +1854,10 @@ export class SchemaRegistry {
`the tenant contribution (${existingOwner.packageId}) becomes its overlay layer.`
);
} else if (existingOwner && existingOwner.packageId !== packageId) {
throw new Error(
`Object "${fqn}" is already owned by package "${existingOwner.packageId}". ` +
`Package "${packageId}" cannot claim ownership. Use 'extend' to add fields.`
);
// [ADR-0029 D3] Two packages claiming one name — the cross-package
// refusal, carried as an ADR-0112 envelope (`code` + `status: 422`)
// with the message text unchanged. See {@link ObjectOwnershipConflictError}.
throw new ObjectOwnershipConflictError(fqn, existingOwner.packageId, packageId);
} else if (existingOwner) {
// Remove existing owner contribution from same package (re-registration).
// Normal path (metadata rebuild / HMR / multi-project seed replays the
Expand Down
32 changes: 32 additions & 0 deletions packages/runtime/src/dispatcher-error-vocabulary.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -744,6 +744,38 @@ export const UNREGISTERED_CODE_SITES: readonly UnregisteredCodeSite[] = [
'evidence of a door. If an install door ever answers with this code itself, the verdict becomes ' +
'pending-registration and it belongs in the ledger batch.'
},
{
code: 'OBJECT_OWNERSHIP_CONFLICT',
file: 'packages/objectql/src/registry.ts',
shape: 'classfield',
door: 'none',
verdict: 'boot-refusal',
why:
'ADR-0029 D3 — the refusal for a package claiming `own` on an object name a DIFFERENT package ' +
'already owns, raised by `SchemaRegistry.registerObject` (the ONE spelling of this refusal — ' +
'the ADR-0029 D9 §6.1 late-install branch beside it re-classifies a tenant-authored ' +
'sitting owner and refuses nothing). Measured on this tree, every path to it either aborts boot ' +
'or catches below any door. `ObjectQL.registerApp` (`packages/objectql/src/engine.ts`) lets it ' +
'propagate to `ManifestService.register()` (`packages/objectql/src/plugin.ts`), whose callers ' +
'are the population the three ADR-0130 rows above record: boot-time `manifest.register()` ' +
'inside plugin init (`packages/runtime/src/app-plugin.ts`, the platform app plugins, the ' +
'service plugins), where a throw aborts boot before any HTTP boundary exists; the rehydrate ' +
'loop in `packages/cloud-connection/src/marketplace-install-local-plugin.ts`, which catches per ' +
'entry and logs; and the import route in that same file, which catches and answers with its ' +
'OWN registered `PLUGIN_REGISTER_FAILED` at 422, interpolating this refusal\'s MESSAGE into ' +
'that envelope. Every other caller catches it in-process: `ObjectQL.registerPlugin` ' +
'(`logger.warn`), the `ObjectQLPlugin` metadata bridge\'s reload ingest and `subscribe(\'object\')` ' +
'handler (`logger.warn`), and `metadata-protocol`\'s `applyObjectRegistryMutation` ' +
'(`console.warn`) and `loadMetaFromDb` (the per-record `errors` count). The two HTTP install ' +
'sites — `POST /packages` in `packages/runtime/src/domains/packages.ts` and ' +
'`protocol.installPackage` — call `SchemaRegistry.installPackage`, which records the package ' +
'and never calls `registerObject`, so neither can raise it; `MetadataFacade.register(\'object\')` ' +
'would propagate it, and has no production instantiation. So the code reaches a reader only ' +
'inside a message string, never as `error.code`. Its `status: 422` is the ADR-0112 envelope ' +
'shape this repo\'s rejection tests assert on, not evidence of a door. If a door ever answers ' +
'with this code itself, the verdict becomes pending-registration and it belongs in the ledger ' +
'batch.'
},
// ── [#13233] field-level catalogs, reached by the OBJECT-LITERAL helper ──
//
// The 29 rows below are the whole verdict cost of widening `codehelper` to
Expand Down
Loading