From 6a2b1f4809b099ee0c0e84c3347ef1ae82564327 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 16:31:43 +0000 Subject: [PATCH 1/3] fix(objectql, metadata-protocol, cloud-connection): close the declared action override's door and boot-order loose ends - install door: judge a declared action override against the base's code artifact, not the listing that serves the armed override - protocol door: a declared action override's base is keyed :, as os validate and the install door key it - registerAction: only the declared base's handler slots beneath an armed override; any other registration replaces it and is warned Claude-Session: https://claude.ai/code/session_01Utp6CXTiQqG1Y4zdXMck85 Co-authored-by: Claude --- ...tplace-install-local-override-gate.test.ts | 46 ++++++++++++++++- .../src/marketplace-install-local-plugin.ts | 21 +++++++- .../src/protocol-packaged-base-refusal.ts | 28 +++++++++-- packages/objectql/src/engine-actions.ts | 50 +++++++++++++++---- packages/objectql/src/engine.ts | 23 ++++++--- .../src/plugin-authored-actions.test.ts | 35 +++++++++++++ packages/objectql/src/plugin.ts | 6 ++- .../src/protocol-flow-custom-override.test.ts | 19 +++++++ 8 files changed, 200 insertions(+), 28 deletions(-) diff --git a/packages/cloud-connection/src/marketplace-install-local-override-gate.test.ts b/packages/cloud-connection/src/marketplace-install-local-override-gate.test.ts index 0c2f7231574..5ea0b1896fc 100644 --- a/packages/cloud-connection/src/marketplace-install-local-override-gate.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-override-gate.test.ts @@ -44,6 +44,8 @@ const deps = (...ids: string[]) => Object.fromEntries(ids.map((id) => [id, '*']) /** What each installed package holds, as the protocol's per-package listing answers it. */ type Holdings = Record>>>; +/** [#30542] The registry's code artifacts (`getArtifactItem`), each under its package's `_packageId`. */ +type Artifacts = Array<{ type: string; item: Record }>; function makeRawApp() { const routes = new Map(); @@ -59,7 +61,7 @@ function makeRawApp() { * A kernel whose registry holds `installed` (BASE read-only — a code or * marketplace package is in `manifests`) and whose protocol lists `holdings`. */ -function makeCtx(rawApp: any, installed: Array<{ id: string; dependencies?: Record }>, holdings: Holdings) { +function makeCtx(rawApp: any, installed: Array<{ id: string; dependencies?: Record }>, holdings: Holdings, artifacts?: Artifacts) { const hooks = new Map(); const registered: any[] = []; const saved: Array<[string, string]> = []; @@ -77,6 +79,11 @@ function makeCtx(rawApp: any, installed: Array<{ id: string; dependencies?: Reco registry: { getAllPackages: () => [...records, ...registered], getPackage: (id: string) => records.find((r) => r.manifest.id === id), + // [#30542] The registry's code artifacts, when the case gives them. + ...(artifacts ? { + getArtifactItem: (type: string, name: string, packageId?: string) => artifacts + .find((a) => a.type === type && a.item.name === name && a.item._packageId === packageId)?.item, + } : {}), }, }), protocol: { @@ -132,9 +139,10 @@ async function install( manifest: Record, installed: Array<{ id: string; dependencies?: Record }> = [{ id: BASE }], holdings: Holdings = BASE_HOLDINGS, + artifacts?: Artifacts, ) { const rawApp = makeRawApp(); - const k = makeCtx(rawApp, installed, holdings); + const k = makeCtx(rawApp, installed, holdings, artifacts); const plugin = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir: dir }); await plugin.start(k.ctx as any); await k.fire(); @@ -299,3 +307,37 @@ describe('install-local 2d — the override write gate at the managed install (A expect(saved).toEqual([['email_template', 'crm_welcome'], ['email_template', 'crm_welcome']]); }); }); + +/** + * [#30542] A re-install of the pair is judged against the base's code + * artifact, as the protocol door judges the save. Once the pair's declared + * action override is armed, the protocol's listing of the base serves that + * override under the base's envelope (measured on the real engine and + * protocol); the registry still holds the base's own action. + */ +describe('[#30542] the install door judges a declared action override against the base\'s code artifact', () => { + const overrides = { package: BASE, basedOn: '1.0.0' }; + const BASE_GATES = { requiredPermissions: ['crm_admin'], ai: { requiresConfirmation: true } }; + // v1, armed: it added a capability to the base's. + const armed: Holdings = { + [BASE]: { action: [{ name: 'crm_call', label: 'V1', overrides, ...BASE_GATES, requiredPermissions: ['crm_admin', 'crm_auditor'] }] }, + }; + const artifacts: Artifacts = [{ type: 'action', item: { name: 'crm_call', label: 'Call', ...BASE_GATES, _packageId: BASE } }]; + const pairOf = (action: Record) => + ({ id: PAIR, version: '2.0.0', namespace: 'crm_custom', dependencies: deps(BASE), actions: [action] }); + + it('a v2 that relaxes the v1 tightening back to the base\'s own gates installs', async () => { + const { res, saved } = await install(pairOf({ name: 'crm_call', label: 'V2', overrides, ...BASE_GATES }), [{ id: BASE }], armed, artifacts); + expect(res.status, JSON.stringify(res.payload)).toBe(200); + expect(saved).toEqual([['action', 'crm_call']]); + }); + + it('a v2 that drops a gate the base\'s own action carries is still refused 403, and nothing is written', async () => { + const wider = { name: 'crm_call', label: 'V2', overrides, ...BASE_GATES, ai: {} }; + const { res, saved, ledger } = await install(pairOf(wider), [{ id: BASE }], armed, artifacts); + expect(res.status, JSON.stringify(res.payload)).toBe(403); + expect(res.payload?.error?.code).toBe('NOT_OVERRIDABLE'); + expect(refusedOf(res)).toEqual([['action', 'crm_call', 'declared_action_widens_access']]); + expect([saved, ledger]).toEqual([[], []]); + }); +}); diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts index 7961d02321d..3946a228af7 100644 --- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts +++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts @@ -568,6 +568,8 @@ type NamespaceHoldingEngine = PackageWritabilityEngine & { type OverrideGateEngine = PackageWritabilityEngine & { registry?: PackageWritabilityEngine['registry'] & { getAllPackages?(): unknown; + /** [#30542] An installed package's code artifact (`SchemaRegistry#getArtifactItem`). */ + getArtifactItem?(type: string, name: string, currentPackageId?: string): unknown; }; }; type OverrideGateLister = { @@ -3681,6 +3683,20 @@ export class MarketplaceInstallLocalPlugin implements Plugin { return read; }; const isBase = (id: string): boolean => delivered.has(id) || !isWritablePackage(ql, id); + // [#30542] An installed base's own item is its code artifact, the one + // the protocol door judges a declared action override against + // (`overrideWriteVerdict`'s `baseItemOf`). The listing is not: once + // the base's declared override is armed, it serves the override under + // the base's envelope, so a re-install of the pair was judged against + // its own previous version, and a v2 that relaxes a v1 tightening + // back to the base's own gates was refused. A registry that answers + // no artifact leaves the listed item. + const artifactOf = (item: OverrideWriteItem, base: string): unknown => { + const artifact = registry.getArtifactItem?.(item.type, item.name, base); + return isPlainObject(artifact) && artifact._packageId === base && listedIdentity(item.type, artifact) === item.identity + ? artifact + : undefined; + }; const refusals: OverrideInstallRefusal[] = []; for (const [packageId, pkg] of delivered) { @@ -3693,7 +3709,10 @@ export class MarketplaceInstallLocalPlugin implements Plugin { const shipped = new Map(); for (const base of bases) { const held = (await heldBy(item.type, base)).get(item.identity); - if (held !== undefined) { shippers.push(base); shipped.set(base, held); } + if (held !== undefined) { + shippers.push(base); + shipped.set(base, (delivered.has(base) ? undefined : artifactOf(item, base)) ?? held); + } } const verdict = judgeOverrideWrite( { type: item.type, name: item.name, packageId, item: item.item }, diff --git a/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts b/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts index 334de532bbf..27e8e71cb1a 100644 --- a/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts +++ b/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts @@ -766,6 +766,17 @@ export function overrideWriteIdentityOf(type: string, identity: string, item: un return member === undefined ? identity : `${identity}\u0000${member}`; } +/** + * [#30542] The object an `action` item is keyed on under the override write + * gate — its `objectName`, `''` when it names none: the first half of the + * `:` identity {@link overrideWriteItemsOf} gives it, and + * what {@link overrideWriteVerdict} matches a declared override's base on. + */ +function overrideWriteActionObjectOf(item: unknown): string { + const objectName = isPlainRecord(item) ? item.objectName : undefined; + return typeof objectName === 'string' ? objectName : ''; +} + /** One item written into one package — what the override write gate judges. */ export interface OverrideWriteSubject { /** The metadata type, either spelling (folded here). */ @@ -1112,9 +1123,8 @@ export function overrideWriteItemsOf(body: Record, at = ''): Ov } continue; } - const identity = type === 'action' && typeof item.objectName === 'string' && item.objectName !== '' - ? `${item.objectName}:${declaredName}` - : declaredName; + const object = type === 'action' ? overrideWriteActionObjectOf(item) : ''; + const identity = object !== '' ? `${object}:${declaredName}` : declaredName; out.push({ type, name: declaredName, identity: overrideWriteIdentityOf(type, identity, item), item, path }); } } @@ -1171,11 +1181,19 @@ export function overrideWriteVerdict( if (packageId !== undefined && scope === undefined) return NOT_CONCERNED; const dependencies = scope?.dependencies.filter((id) => id !== packageId); const member = overrideWriteMemberOf(request.type, request.item); + // [#30542] A declared action override names its base's action by the + // identity `os validate` and the install door key it on, + // `:` ({@link overrideWriteItemsOf}): the base's action + // of that name on another object is no shipper of it, so an override that + // moves the action to another object is refused here as it is there. + const onItsObject = (a: unknown): boolean => !declaration.present + || (PLURAL_TO_SINGULAR[request.type] ?? request.type) !== 'action' + || overrideWriteActionObjectOf(a) === overrideWriteActionObjectOf(request.item); const shippers = dependencies === undefined || dependencies.length === 0 ? [] : Array.from(new Set( this.shippedArtifactsOf(request.type, request.name, undefined, member) - .filter((a) => overrideWriteMemberOf(request.type, a) === member) + .filter((a) => overrideWriteMemberOf(request.type, a) === member && onItsObject(a)) .map((a) => (a as { _packageId?: unknown } | null | undefined)?._packageId) // An override replaces an item of an INSTALLED, read-only base // (ADR-0146 D1): a writable package's item is edited in place @@ -1188,7 +1206,7 @@ export function overrideWriteVerdict( // [#30065] The base's own item, the one a declared action override is // judged against: the shipping artifact under that base's envelope. const baseItemOf = (basePackageId: string): unknown => this.shippedArtifactsOf(request.type, request.name, undefined, member) - .find((a) => overrideWriteMemberOf(request.type, a) === member + .find((a) => overrideWriteMemberOf(request.type, a) === member && onItsObject(a) && (a as { _packageId?: unknown } | null | undefined)?._packageId === basePackageId); return judgeOverrideWrite(request, { dependencies, shippers, baseItemOf }); } diff --git a/packages/objectql/src/engine-actions.ts b/packages/objectql/src/engine-actions.ts index eaa5ca936c8..a13d83923d5 100644 --- a/packages/objectql/src/engine-actions.ts +++ b/packages/objectql/src/engine-actions.ts @@ -33,17 +33,21 @@ import type { ActionActivationRow, ActionActivationStore, ActionActivationWriteOptions } from './action-activation.js'; import type { ObjectQL } from './engine.js'; +import { standaloneActionOwnerKey } from './action-governance.js'; /** * One registered action handler. [#30065] `override` marks a declared action * override's handler ({@link ActionsFacet.registerActionOverride}); `beneath` - * is the handler it was bound over, or one registered for the key after it, - * kept so removing the override restores it. + * is the handler it was bound over, or the declared base's registered for the + * key after it, kept so removing the override restores it. [#30542] `base` is + * the package the override declares (`overrides.package`): only a handler of + * the action that package ships slots beneath it. */ export interface ActionHandlerEntry { handler: (ctx: any) => Promise | any; package?: string; override?: true; + base?: string; beneath?: ActionHandlerEntry; } @@ -58,21 +62,24 @@ export interface ActionsHost { readonly actionActivation: ObjectQL['actionActivation']; readonly actions: ObjectQL['actions']; readonly logger: ObjectQL['logger']; + readonly registry: ObjectQL['registry']; } /** The bodies of the "Action system and packaged-action activation" section of `ObjectQL`: a stateless facet, called by the class's stubs. */ export class ActionsFacet { /** The body of `ObjectQL#registerAction`; the method's docblock there is its contract. */ - static registerAction(this: Pick, objectName: string, actionName: string, handler: (ctx: any) => Promise | any, packageName?: string): void { + static registerAction(this: Pick, objectName: string, actionName: string, handler: (ctx: any) => Promise | any, packageName?: string): void { const key = `${objectName}:${actionName}`; const replaced = this.actions.get(key); - // [#30065, ADR-0146 D9 `action`] Another package's handler arriving under - // a key a declared override holds — a marketplace base's, bound by its + // [#30065, ADR-0146 D9 `action`] The declared base's handler arriving + // under a key the override holds — a marketplace base's, bound by its // `kernel:ready` rehydrate after the authored-action resync bound the - // override, or a bundle's handlers re-bound — is the base's: it slots - // beneath the override, which stays the one that runs, and is the one a - // removal of the override binds again. - if (replaced?.override === true && replaced.package !== packageName) { + // override, or a bundle's handlers re-bound — slots beneath the override, + // which stays the one that runs, and is the one a removal of the override + // binds again. [#30542] Only the declared base's: any other registration + // replaces it and is warned below, like every other collision. + if (replaced?.override === true && replaced.package !== packageName + && ActionsFacet.arrivesAsDeclaredBase.call(this, replaced, objectName, actionName, packageName)) { this.actions.set(key, { ...replaced, beneath: { handler, package: packageName } }); this.logger.debug('Registered action beneath an override', { objectName, actionName, package: packageName, override: replaced.package }); return; @@ -92,14 +99,35 @@ export class ActionsFacet { this.logger.debug('Registered action', { objectName, actionName, package: packageName }); } + /** + * [#30542] Whether a registration under a key a declared override holds is + * the declared base's own handler: the registry's code artifact of the + * action (`getArtifactItem`, which a package's items are registered in + * before its handlers are bound) belongs to the package the override + * declares, on this object — the judgement the authored-action resync + * binds the override by once that artifact is registered. A registration + * with no package is no package's. An override bound with no declared base + * keeps taking any package's handler beneath it. + */ + static arrivesAsDeclaredBase(this: Pick, override: ActionHandlerEntry, objectName: string, actionName: string, packageName: string | undefined): boolean { + if (packageName === undefined) return false; + if (override.base === undefined) return true; + const artifact: any = this.registry?.getArtifactItem?.('action', actionName); + return artifact?._packageId === override.base && standaloneActionOwnerKey(artifact) === objectName; + } + /** The body of `ObjectQL#registerActionOverride`; the method's docblock there is its contract. */ - static registerActionOverride(this: Pick, objectName: string, actionName: string, handler: (ctx: any) => Promise | any, packageName: string): void { + static registerActionOverride(this: Pick, objectName: string, actionName: string, handler: (ctx: any) => Promise | any, packageName: string, base?: string): void { const key = `${objectName}:${actionName}`; const current = this.actions.get(key); // Re-binding the same package's override replaces it in place, over the // same handler beneath. const beneath = current?.package === packageName ? current.beneath : current; - this.actions.set(key, { handler, package: packageName, override: true, ...(beneath !== undefined ? { beneath } : {}) }); + this.actions.set(key, { + handler, package: packageName, override: true, + ...(base !== undefined ? { base } : {}), + ...(beneath !== undefined ? { beneath } : {}), + }); this.logger.debug('Registered action override', { objectName, actionName, package: packageName, beneath: beneath?.package }); } diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 1afa664d1ba..ec9b6c70a53 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -1185,11 +1185,15 @@ export class ObjectQL implements IObjectQLEngine { * * [#30065, ADR-0146 D9 `action`] The one exception: a key a declared * action override holds ({@link registerActionOverride}) keeps the - * override on top, and another package's registration takes the place - * beneath it, the handler a removal of the override binds again. A - * marketplace base's handlers are bound by its rehydrate after the - * authored-action resync bound the override, so the base's body arrives - * second and must not replace the override's. + * override on top when the declared base's handler arrives, and that + * handler takes the place beneath it, the one a removal of the override + * binds again. A marketplace base's handlers are bound by its rehydrate + * after the authored-action resync bound the override, so the base's body + * arrives second and must not replace the override's. [#30542] The + * declared base's handler is the one registered while the registry's code + * artifact of the action is that package's, on this object; any other + * registration under the key, one with no package included, replaces the + * override and is warned like every other. */ registerAction(objectName: string, actionName: string, handler: (ctx: any) => Promise | any, packageName?: string): void { // [#29986 codemod] The body moved to `engine-actions.ts` (see `ActionsHost`). @@ -1210,9 +1214,13 @@ export class ObjectQL implements IObjectQLEngine { * warned: the replacement is the declared one. The entry is marked as an * override, so the base's handler registered after it (by * {@link registerAction}) slots beneath it instead of replacing it. + * + * @param base [#30542] The package the override declares it overrides + * (`overrides.package`). Only that package's handler slots beneath the + * override; without it, any package's does. */ - registerActionOverride(objectName: string, actionName: string, handler: (ctx: any) => Promise | any, packageName: string): void { - return ActionsFacet.registerActionOverride.call(actionsHost.of(this), objectName, actionName, handler, packageName); + registerActionOverride(objectName: string, actionName: string, handler: (ctx: any) => Promise | any, packageName: string, base?: string): void { + return ActionsFacet.registerActionOverride.call(actionsHost.of(this), objectName, actionName, handler, packageName, base); } /** @@ -10431,6 +10439,7 @@ export class ObjectQL implements IObjectQLEngine { get actionActivation() { return engine.actionActivation; }, get actions() { return engine.actions; }, get logger() { return engine.logger; }, + get registry() { return engine.registry; }, })); cascadePlanningHost.install((engine) => ({ get _registry() { return engine._registry; }, diff --git a/packages/objectql/src/plugin-authored-actions.test.ts b/packages/objectql/src/plugin-authored-actions.test.ts index 2c4331291e1..5bef2246fc3 100644 --- a/packages/objectql/src/plugin-authored-actions.test.ts +++ b/packages/objectql/src/plugin-authored-actions.test.ts @@ -18,6 +18,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { ObjectQLPlugin } from './plugin.js'; import { ObjectQL } from './engine.js'; +import { isServedOverrideRowOf } from '@objectstack/metadata-protocol'; type AnyRecord = Record; @@ -431,6 +432,29 @@ describe('[#30065] a declared .custom action override rebinds the body, and rest } }); + it('[#30542] before the base is registered, another base\'s declared override of the same key is replaced by the real base\'s handler, as every read declines it', async () => { + // A row of `com.example.other`'s pair declaring that package, for the key + // the real base ships: bound on its own declaration while no artifact is + // registered, it is not the real base's override. + const OTHER = 'com.example.other'; + const row = overrideRow(`${OTHER}.custom`, 'OTHER BASE', { overrides: { package: OTHER, basedOn: '1.0.0' } }); + const { ql, resync, run, bindBase } = harness({ baseBound: false }); + await resync([row]); + await expect(run()).resolves.toBe('ran:OTHER BASE'); + const warn = vi.spyOn((ql as any).logger, 'warn'); + bindBase(); + await expect(run()).resolves.toBe('ran:BASE BODY'); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][1]).toMatchObject({ package: `app:${BASE}`, replacedPackage: 'metadata-service' }); + // The reads judge the row against the real base by the one served-override + // predicate, and decline it: the run now agrees with them. + expect([isServedOverrideRowOf(row as any, BASE), isServedOverrideRowOf(row as any, OTHER)]).toEqual([false, true]); + // The next resync judges the row against the registered artifact and + // leaves the base's handler bound. + await resync([row]); + await expect(run()).resolves.toBe('ran:BASE BODY'); + }); + it('re-binding the armed override keeps the base beneath it, so a later restore still runs the base body', async () => { const { resync, run } = harness(); await resync([overrideRow(PAIR, 'FIRST')]); @@ -458,6 +482,17 @@ describe('[#30065] a declared .custom action override rebinds the body, and rest await expect(run()).resolves.toBe('ran:BASE BODY'); }); + it('[#30542] a registration with no package under an armed override replaces it and is warned, naming both owners', async () => { + const ql = new ObjectQL(); + ql.registerAction(OBJ, ACT, async () => 'base', `app:${BASE}`); + ql.registerActionOverride(OBJ, ACT, async () => 'override', 'metadata-service', BASE); + const warn = vi.spyOn((ql as any).logger, 'warn'); + ql.registerAction(OBJ, ACT, async () => 'unpackaged'); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][1]).toMatchObject({ objectName: OBJ, actionName: ACT, package: undefined, replacedPackage: 'metadata-service' }); + await expect(ql.executeAction(OBJ, ACT, {})).resolves.toBe('unpackaged'); + }); + it('the handler stack: removing the base under an armed override keeps the override, and removing both unbinds the name', async () => { const ql = new ObjectQL(); ql.registerAction(OBJ, ACT, async () => 'base', `app:${BASE}`); diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts index eb750f2b7e3..dbd70a1f289 100644 --- a/packages/objectql/src/plugin.ts +++ b/packages/objectql/src/plugin.ts @@ -3782,7 +3782,9 @@ export class ObjectQLPlugin implements Plugin { * the boot's resync. Bound as an override then, the row's handler stays on * top when the base's arrives, which slots beneath it * (`ObjectQL#registerAction`), so the reads and the run agree from the - * first request. + * first request. [#30542] Only the base the row declares slots beneath: + * a row of another base's pair that ships the same key is replaced by the + * real base's handler when it arrives, as the reads decline it. */ private isDeclaredArtifactActionOverride(row: unknown, action: any): boolean { const registry: any = this.ql?.registry; @@ -4203,7 +4205,7 @@ export class ObjectQLPlugin implements Plugin { // the teardown above restores when the override is gone: one resync // rebinds the name, in either direction, with no window between. if (this.declaredActionOverrides.has(action) && typeof ql.registerActionOverride === 'function') { - ql.registerActionOverride(standaloneActionOwnerKey(action), action.name, handler, 'metadata-service'); + ql.registerActionOverride(standaloneActionOwnerKey(action), action.name, handler, 'metadata-service', action.overrides?.package); } else { ql.registerAction(standaloneActionOwnerKey(action), action.name, handler, 'metadata-service'); } diff --git a/packages/objectql/src/protocol-flow-custom-override.test.ts b/packages/objectql/src/protocol-flow-custom-override.test.ts index f93f85871c4..13d16cd6340 100644 --- a/packages/objectql/src/protocol-flow-custom-override.test.ts +++ b/packages/objectql/src/protocol-flow-custom-override.test.ts @@ -505,6 +505,25 @@ describe('[#30065] a declared .custom action override is the packaged action, at expect((await p.getMetaItemLayered({ type: 'action', name: ACTION })).effective.label).toBe('SHIPPED'); }); + it('[#30542] an override that moves the action to another object is refused at the save door, by the rule os validate and the install door give it', async () => { + const { protocol, engine } = await boot(); + await install(protocol, PAIR); + const moved = actionBody('MOVED', { ...declared, objectName: 'showcase_other' }); + const refusal = await protocol.saveMetaItem({ + type: 'action', name: ACTION, item: moved, mode: 'draft', packageId: PAIR, + } as any).then(() => undefined, (e: any) => e); + expect(refusal).toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); + // `os validate` and the install door key the base's action + // `:`, so the base ships no such item there. + expect((protocol as any).overrideWriteVerdict({ type: 'action', name: ACTION, packageId: PAIR, item: moved })) + .toMatchObject({ verdict: 'refuse', rule: 'declared_package_ships_no_such_item' }); + expect(await stored(engine)).toEqual([]); + expect(await everyRead(protocol, engine)).toEqual(servedAs('SHIPPED')); + // CONTROL: on the base's own object the same declaration is admitted. + expect((protocol as any).overrideWriteVerdict({ type: 'action', name: ACTION, packageId: PAIR, item: actionBody('OURS', declared) })) + .toMatchObject({ verdict: 'admit' }); + }); + it('the pair\'s undeclared save is refused, and the remedy names the declaration, never a clone', async () => { const { protocol } = await boot(); await install(protocol, PAIR); From da065aea38ed0680f2eb66b14ba76f68ce335e7a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 16:36:16 +0000 Subject: [PATCH 2/3] chore(changeset): declare the action override door and boot-order fixes Claude-Session: https://claude.ai/code/session_01Utp6CXTiQqG1Y4zdXMck85 Co-authored-by: Claude --- ...42-action-override-doors-and-boot-order.md | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 .changeset/30542-action-override-doors-and-boot-order.md diff --git a/.changeset/30542-action-override-doors-and-boot-order.md b/.changeset/30542-action-override-doors-and-boot-order.md new file mode 100644 index 00000000000..6b563359c71 --- /dev/null +++ b/.changeset/30542-action-override-doors-and-boot-order.md @@ -0,0 +1,22 @@ +--- +'@objectstack/metadata-protocol': minor +'@objectstack/objectql': minor +'@objectstack/cloud-connection': minor +--- + +fix(objectql, metadata-protocol, cloud-connection)!: a declared `.custom` action override is keyed and judged the same way at every door, and only its declared base's handler slots beneath it + +Clause-②: yes (narrowing) — the protocol door (save and publish) now refuses a declared action override whose `objectName` is not the base action's, with `403 NOT_OVERRIDABLE` and rule `declared_package_ships_no_such_item`, the refusal `os validate` and the managed install already gave it; the save door used to admit it. The managed install now admits a re-install of the pair whose override relaxes an earlier tightening back to the base's own gates, which it used to refuse with `declared_action_widens_access` while the save door admitted it. Every other input answers as before. +Metadata-protocol: narrowed +Skills-docs: follow-up #30505 — the declared action override's docs card; it owes one sentence that an override keeps the base action's `objectName`. + + + +**BREAKING** accept-set narrowing at the protocol door (`@objectstack/metadata-protocol`, ADR-0146 D2/D9): a declared action override names its base's action by `:`, the identity `os validate` and the managed install key it on. + +- **Before.** The save and publish doors matched a declared action override's base by name alone, so an override in the base's `.custom` pair could move the base's action to another object. Its handler was not rebound there, and `os validate` and the install door refused the same body. +- **Now.** The save door refuses it as the other two doors do: the base ships no action of that name on that object. Keep the base action's `objectName` in the override, or remove `overrides` and add the action under the pair's own name. + +`@objectstack/cloud-connection`: the managed install judges a declared action override against the installed base's code artifact (`SchemaRegistry#getArtifactItem`), as the save door does. The protocol's per-package listing it read before serves an armed override under the base's envelope, so a re-install of the pair was judged against its own previous version. + +`@objectstack/objectql`: `ObjectQL#registerActionOverride` takes the package the override declares (`overrides.package`) as an optional fifth argument, and the authored-action resync passes it. `registerAction` under a key such an override holds slots the handler beneath it only when the registry's code artifact of the action belongs to that package, on that object; any other registration, one with no package included, replaces the override and is warned like every other handler collision. Before, any other package's registration slotted beneath it, so a row of another base's pair bound before the real base registered kept running after the real base's handler arrived, while every read served the real base's action; and a registration with no package slotted beneath it at `debug`, where the method's docblock promises a warning. From accb6240985b185dd97422ccb4504f535631aded Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 18:28:10 +0000 Subject: [PATCH 3/3] chore(changeset): declare the action override fixes Metadata-protocol none, patch The protocol did not narrow: os validate and the install door already refused a moved-object override; the save door caught up. Claude-Session: https://claude.ai/code/session_01Utp6CXTiQqG1Y4zdXMck85 Co-authored-by: Claude --- .changeset/30542-action-override-doors-and-boot-order.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.changeset/30542-action-override-doors-and-boot-order.md b/.changeset/30542-action-override-doors-and-boot-order.md index 6b563359c71..43ee9b53649 100644 --- a/.changeset/30542-action-override-doors-and-boot-order.md +++ b/.changeset/30542-action-override-doors-and-boot-order.md @@ -1,13 +1,13 @@ --- -'@objectstack/metadata-protocol': minor -'@objectstack/objectql': minor -'@objectstack/cloud-connection': minor +'@objectstack/metadata-protocol': patch +'@objectstack/objectql': patch +'@objectstack/cloud-connection': patch --- fix(objectql, metadata-protocol, cloud-connection)!: a declared `.custom` action override is keyed and judged the same way at every door, and only its declared base's handler slots beneath it Clause-②: yes (narrowing) — the protocol door (save and publish) now refuses a declared action override whose `objectName` is not the base action's, with `403 NOT_OVERRIDABLE` and rule `declared_package_ships_no_such_item`, the refusal `os validate` and the managed install already gave it; the save door used to admit it. The managed install now admits a re-install of the pair whose override relaxes an earlier tightening back to the base's own gates, which it used to refuse with `declared_action_widens_access` while the save door admitted it. Every other input answers as before. -Metadata-protocol: narrowed +Metadata-protocol: none Skills-docs: follow-up #30505 — the declared action override's docs card; it owes one sentence that an override keeps the base action's `objectName`.