diff --git a/.changeset/registry-object-ownership-refusal-envelope.md b/.changeset/registry-object-ownership-refusal-envelope.md new file mode 100644 index 0000000000..6f93504e1a --- /dev/null +++ b/.changeset/registry-object-ownership-refusal-envelope.md @@ -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`). diff --git a/packages/objectql/src/metadata-facade.test.ts b/packages/objectql/src/metadata-facade.test.ts index 248158b024..f8b2957d0d 100644 --- a/packages/objectql/src/metadata-facade.test.ts +++ b/packages/objectql/src/metadata-facade.test.ts @@ -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'); diff --git a/packages/objectql/src/registry-object-overlay-layer.test.ts b/packages/objectql/src/registry-object-overlay-layer.test.ts index d03b5f6843..3fc6636bf3 100644 --- a/packages/objectql/src/registry-object-overlay-layer.test.ts +++ b/packages/objectql/src/registry-object-overlay-layer.test.ts @@ -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(); @@ -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"/); }); /** @@ -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']); }); diff --git a/packages/objectql/src/registry-ownership-refusal-envelope.test.ts b/packages/objectql/src/registry-ownership-refusal-envelope.test.ts new file mode 100644 index 0000000000..1666a0af67 --- /dev/null +++ b/packages/objectql/src/registry-ownership-refusal-envelope.test.ts @@ -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); + }); +}); diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index 3dc402332c..5a2abe9e74 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -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 @@ -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, @@ -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 diff --git a/packages/runtime/src/dispatcher-error-vocabulary.ts b/packages/runtime/src/dispatcher-error-vocabulary.ts index ed9ebf08ed..3bc491c12d 100644 --- a/packages/runtime/src/dispatcher-error-vocabulary.ts +++ b/packages/runtime/src/dispatcher-error-vocabulary.ts @@ -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