Skip to content

Commit 63de248

Browse files
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 (#30612)
domain:server#3 · os-bill · session_01Utp6CXTiQqG1Y4zdXMck85 <!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wg1KVSeo3u2QVwiYBHqKjeot)_ Fixes #30542 Clause-②: yes (narrowing) Metadata-protocol: none Skills-docs: follow-up #30505 ## 维护者速读 1. 改了什么:PR #30497 留下的四处收口。安装门按基包的代码原件判断动作覆盖;保存门把动作覆盖的基包按 `OBJECT:NAME` 认,和 `os validate`、安装门一致;只有覆盖声明的那个基包的 handler 才垫在覆盖下面,别的注册(包括不带包名的)照常替换并告警。 2. 为什么改:#30542。四处都是保存门、安装门、读和运行之间的分歧:安装门会误拒一个把收紧放回基包原样的 v2;保存门会放行一个把动作挪到别的对象上的覆盖;另一个基包的 `.custom` 行在真基包注册前绑上后会一直跑,而读已经不认它;不带包名的注册垫到覆盖下面时文档说告警、实际没有。 3. 风险与回滚:BREAKING 只有一条,保存门现在拒绝把动作挪到别的对象上的声明覆盖(403 `NOT_OVERRIDABLE`,规则 `declared_package_ships_no_such_item`),这条形状 `os validate` 和安装门本来就拒。其余三处只改判断依据或日志。单一提供方的正常部署不受影响。回滚即 revert。 4. 先看:`engine-actions.ts` 的 `arrivesAsDeclaredBase`(运行时谁能垫在覆盖下面);`protocol-packaged-base-refusal.ts` 的 `onItsObject`;`marketplace-install-local-plugin.ts` 的 `artifactOf`。 5. 两分钟验证:`pnpm --filter @objectstack/objectql exec vitest run src/plugin-authored-actions.test.ts src/protocol-flow-custom-override.test.ts`,全绿(24 + 14)。 6. 需要你做的:无,席位落地。 ## What changes Four loose ends PR #30497 left in the declared `.custom` action override (ADR-0146 D9), from its contract reviews (round 0 ③-2, ③-3; delta ③-7, ③-8). Each was measured on `main` (`db04e5ce5`) first, on the real engine, protocol and install door; all four reproduced. **1. Install-door ratchet** (`packages/cloud-connection/src/marketplace-install-local-plugin.ts`) - The managed install's override gate now hands the judge the installed base's code artifact (`registry.getArtifactItem(type, name, base)`, kept only when it is that base's, at the same `OBJECT:NAME` identity) as `baseItemOf`, the item the protocol door's `overrideWriteVerdict` judges against. A registry that answers no artifact leaves the listed item, as before. A base delivered in the same install is still read off its body. - Before, it handed the judge the item the protocol's per-package listing served. Once the pair's override is armed, that listing serves the override under the base's envelope, so a re-install of the pair was judged against its own previous version. **2. `objectName` pinned at the protocol door** (`packages/metadata-protocol/src/protocol-packaged-base-refusal.ts`) - `overrideWriteVerdict` keeps, for a *declared* action override, only the shipped artifacts on the override's own `objectName` (as shippers and as `baseItemOf`). An override that moves the base's action to another object therefore finds no shipper and is refused `declared_package_ships_no_such_item`: the rule `os validate` and the install door already gave it, since both key an action `OBJECT:NAME` (`overrideWriteItemsOf`). One private helper, `overrideWriteActionObjectOf`, now spells that object for both. - Chosen over an `action` row in `ITEM_KEY_DISCRIMINATORS`: that table also keys the registry's item collections and the protocol's list merge, so a row there would re-key every action read, far wider than this door. An undeclared item is judged exactly as before. **3. Only the declared base's handler slots beneath an override** (`packages/objectql/src/engine-actions.ts`, `engine.ts`, `plugin.ts`) - `ObjectQL#registerActionOverride` takes an optional fifth argument, `base`: the package the override declares (`overrides.package`). The authored-action resync passes it, and the entry records it. - `registerAction` under a key such an override holds slots the arriving handler beneath it only when the registry's code artifact of the action (`getArtifactItem('action', name)`) is that package's, on this object. That is the judgement the resync itself binds the override by once the artifact is registered, and every path registers a package's items before it binds its handlers (`AppPlugin.start`, the marketplace rehydrate, the install). Any other registration replaces the override and is warned like every other handler collision. - So a row of another base's pair that ships the same `OBJECT:NAME`, bound on its own declaration before the real base registered, is replaced by the real base's handler when it arrives: the run now agrees with the reads, which decline that row for the real base. The next resync judges the row against the registered artifact and does not bind it. Chosen over `rehydrate` announcing `metadata:reloaded`: that signal re-runs every consumer's `kernel:ready` sweep (flows, security, translations) from inside the install plugin's own `kernel:ready`, which is neither cheap nor safe at boot. - An override bound with no `base` keeps taking any package's handler beneath it. **4. Log level: warn** (`engine-actions.ts`, `engine.ts` docblock) - A registration with no package under an armed override is no package's, so it is not the declared base's: it now takes the replace-and-warn path, the one `registerAction`'s docblock promises for an unpackaged registration displacing a package's handler. Before, it slotted beneath at `debug`. The docblock now names the exception precisely (the declared base's handler only). **Declarations.** `Clause-②: yes (narrowing)`, BREAKING: item 2 refuses at the save and publish doors a declared override the save door admitted before. Item 1 admits at the install door what the save door already admitted (a widening arm, outranked by the narrowing). `Metadata-protocol: none`: what an author can write did not narrow, because `os validate` (`findOverrideDeclarationRefusals`) and the managed install already keyed an action OBJECT:NAME on `main` and refused a moved-object override; the save door caught up with them (the #30095 precedent). The changeset carries the ADR-0087 disposition (`not-required (no-migration-prescription)`: no schema key moves; nothing at rest is rewritten). Items 3 and 4 change which handler runs and a log level, not what an author can write. `patch` on the three packages the diff moves. ## Tests - `packages/cloud-connection/src/marketplace-install-local-override-gate.test.ts`, new `describe` "[#30542] the install door judges a declared action override against the base's code artifact": with the armed v1 served by the listing and the base's own action in the registry, a v2 back at the base's gates installs (200, saved), and a v2 that drops a gate the base carries is still refused 403 `declared_action_widens_access`, nothing written. The harness's registry gains an optional `getArtifactItem`; the existing cases pass none and read as before. - `packages/objectql/src/protocol-flow-custom-override.test.ts`, new case in the `[#30065]` `describe` (real protocol over a real engine): a moved-object override is refused at the save door (`code` + `status`), `overrideWriteVerdict` answers rule `declared_package_ships_no_such_item`, nothing is stored and every read still serves the base; control: the same declaration on the base's object is admitted. - `packages/objectql/src/plugin-authored-actions.test.ts`, two new cases on a real engine: the other-base row bound before the base registers runs until the real base binds, then the real base's body runs, one collision warning names both owners, `isServedOverrideRowOf` declines the row for the real base (and admits it for its own), and the next resync leaves the base bound; an unpackaged registration under an armed override replaces it with one warning naming both owners. <details><summary>Measurement on main (db04e5c), then on the fix</summary> Throwaway probes (deleted) on the real engine, protocol and install door, the same harness the #30497 tests use: | Item | Door | `main` | fix | |:--|:--|:--|:--| | 1 | protocol listing of the base after v1 is armed | serves `V1` under `com.example.showcase` | same (unchanged) | | 1 | protocol door, v2 back at the base's gates | ADMITTED | ADMITTED | | 1 | install door gate (`evaluateOverrideWriteGate`), same v2 | refused `declared_action_widens_access` | no refusal | | 2 | protocol door, override with `objectName` moved | ADMITTED | REFUSED 403 `NOT_OVERRIDABLE` | | 2 | install door, same body | refused `declared_package_ships_no_such_item` | same | | 2 | `os validate` (`findOverrideDeclarationRefusals`) | refused `declared_package_ships_no_such_item` | same | | 3 | other-base row resynced before the real base binds, then run | `ran:OTHER BASE` | `ran:BASE BODY` | | 3 | `isServedOverrideRowOf(row, realBase)` (the reads' predicate) | false | false | | 4 | unpackaged `registerAction` under an armed override | slotted beneath, 0 warnings | replaces, 1 warning | </details> <details><summary>Ablations, one per fix</summary> Each through `scripts/ablation-replace.mjs` (literal anchor, on-disk count, restore by trap), the pin run under the mutation: - **Item 1**: `artifactOf(...) ?? held` → `held`: exactly "a v2 that relaxes the v1 tightening back to the base's own gates installs" fails (1 failed, 11 passed). A first attempt with the bare replacement `held` was refused by the tool as a no-op (the count did not move), so it did not run; the second used a marked replacement. - **Item 2** (`metadata-protocol`, read from `dist/`): the object comparison → `!== 'ablate-30542-a2'`, rebuilt, `ablation-dist-preflight` found the marker in 2 built files; exactly "an override that moves the action to another object is refused…" fails (1 failed, 13 passed). Restore leg: rebuilt, `--absent` exit 0, tree clean, suite 14/14. A first attempt (`true ||`) was folded away by the build (marker absent from `dist/`), so its suite never ran. - **Item 3**: the declared-base check → `return true`: exactly "another base's declared override of the same key is replaced…" fails (1 failed, 23 passed). - **Item 4**: `packageName === undefined` → slot beneath: exactly "a registration with no package under an armed override replaces it…" fails (1 failed, 23 passed). </details> ## Acceptance notes - The warning a replaced override logs is the generic collision sentence ("Two owners share one handler key only when both declare action …, rename one of them"), which fits the same-key misconfiguration the review names. - The pair's *undeclared* same-named action on another object is still refused `undeclared_same_name` at the protocol door while `os validate` and the install door admit it (they key it `OBJECT:NAME` and find no collision). That is the pre-existing, stricter direction and outside this card; it narrows nothing new. ## Verification All readings on head `76e59ff17` (the fix merged with `origin/main` at `739c97273`) unless a line says otherwise. Head `accb62409` changes only the changeset's declaration line and levels (no source), so the readings below cover its source; on it `check-changeset-no-major.mjs --base origin/main` exit 0 and `check-adr-0087-registration.mjs --base origin/main` exit 0. - Targeted pins after the merge and rebuild: objectql `plugin-authored-actions` + `protocol-flow-custom-override` 38/38, cloud-connection `marketplace-install-local-override-gate` 12/12, cli `override-declarations` 11/11, metadata-protocol `protocol.packaged-base-refusal` 91/91. - Full suites on the fix before the merge (`da065aea3`): metadata-protocol 22447 passed, objectql 11023 passed, cloud-connection 880 passed; `typecheck` clean on all three. The merge brought in no change to the files this PR touches. - Dogfood battery (title carries `!`): `pnpm --filter @objectstack/dogfood test` after the `@objectstack/dogfood^...` build, lock VERDICT command-exit 0, Test Files 420 passed | 3 skipped (423), Tests 3496 passed | 36 skipped (3532), no failure. <details><summary>dispatch-gates on this head: 62 derived, 62 run, 0 NOT-MEASURED</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` over the PR's change set, each family run and recorded `COMMAND :: exit N`; `--ran` reconciliation: 62 derived, 62 run, 0 NOT-MEASURED, 0 UNRUN, every exit 0. Also `pnpm --filter @objectstack/spec check:liveness` exit 0. On the first run (before the merge) two families did not measure and were re-run green: `check-plugin-teardown-shape --self-test` needed its pinned fixture commit in this shallow clone (fetched), and `check:dual-build-cjs-loads` needed every package built (PREREQUISITE NOT MET until the full build). </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (`packages/spec` not touched.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` is CI's. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: not touched. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CXTiQqG1Y4zdXMck85)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 778aedf commit 63de248

9 files changed

Lines changed: 222 additions & 28 deletions
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
'@objectstack/objectql': patch
4+
'@objectstack/cloud-connection': patch
5+
---
6+
7+
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
8+
9+
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.
10+
Metadata-protocol: none
11+
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`.
12+
13+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped: the action schema and the `overrides` declaration keep their shapes. What moves is which door admits one declared override shape, so all three doors give one answer, and the refusal carries its own remedy; nothing at rest is rewritten. -->
14+
15+
**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 `<objectName>:<name>`, the identity `os validate` and the managed install key it on.
16+
17+
- **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.
18+
- **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.
19+
20+
`@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.
21+
22+
`@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.

‎packages/cloud-connection/src/marketplace-install-local-override-gate.test.ts‎

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ const deps = (...ids: string[]) => Object.fromEntries(ids.map((id) => [id, '*'])
4444

4545
/** What each installed package holds, as the protocol's per-package listing answers it. */
4646
type Holdings = Record<string, Record<string, Array<Record<string, unknown>>>>;
47+
/** [#30542] The registry's code artifacts (`getArtifactItem`), each under its package's `_packageId`. */
48+
type Artifacts = Array<{ type: string; item: Record<string, unknown> }>;
4749

4850
function makeRawApp() {
4951
const routes = new Map<string, Handler>();
@@ -59,7 +61,7 @@ function makeRawApp() {
5961
* A kernel whose registry holds `installed` (BASE read-only — a code or
6062
* marketplace package is in `manifests`) and whose protocol lists `holdings`.
6163
*/
62-
function makeCtx(rawApp: any, installed: Array<{ id: string; dependencies?: Record<string, string> }>, holdings: Holdings) {
64+
function makeCtx(rawApp: any, installed: Array<{ id: string; dependencies?: Record<string, string> }>, holdings: Holdings, artifacts?: Artifacts) {
6365
const hooks = new Map<string, any>();
6466
const registered: any[] = [];
6567
const saved: Array<[string, string]> = [];
@@ -77,6 +79,11 @@ function makeCtx(rawApp: any, installed: Array<{ id: string; dependencies?: Reco
7779
registry: {
7880
getAllPackages: () => [...records, ...registered],
7981
getPackage: (id: string) => records.find((r) => r.manifest.id === id),
82+
// [#30542] The registry's code artifacts, when the case gives them.
83+
...(artifacts ? {
84+
getArtifactItem: (type: string, name: string, packageId?: string) => artifacts
85+
.find((a) => a.type === type && a.item.name === name && a.item._packageId === packageId)?.item,
86+
} : {}),
8087
},
8188
}),
8289
protocol: {
@@ -132,9 +139,10 @@ async function install(
132139
manifest: Record<string, unknown>,
133140
installed: Array<{ id: string; dependencies?: Record<string, string> }> = [{ id: BASE }],
134141
holdings: Holdings = BASE_HOLDINGS,
142+
artifacts?: Artifacts,
135143
) {
136144
const rawApp = makeRawApp();
137-
const k = makeCtx(rawApp, installed, holdings);
145+
const k = makeCtx(rawApp, installed, holdings, artifacts);
138146
const plugin = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir: dir });
139147
await plugin.start(k.ctx as any);
140148
await k.fire();
@@ -299,3 +307,37 @@ describe('install-local 2d — the override write gate at the managed install (A
299307
expect(saved).toEqual([['email_template', 'crm_welcome'], ['email_template', 'crm_welcome']]);
300308
});
301309
});
310+
311+
/**
312+
* [#30542] A re-install of the pair is judged against the base's code
313+
* artifact, as the protocol door judges the save. Once the pair's declared
314+
* action override is armed, the protocol's listing of the base serves that
315+
* override under the base's envelope (measured on the real engine and
316+
* protocol); the registry still holds the base's own action.
317+
*/
318+
describe('[#30542] the install door judges a declared action override against the base\'s code artifact', () => {
319+
const overrides = { package: BASE, basedOn: '1.0.0' };
320+
const BASE_GATES = { requiredPermissions: ['crm_admin'], ai: { requiresConfirmation: true } };
321+
// v1, armed: it added a capability to the base's.
322+
const armed: Holdings = {
323+
[BASE]: { action: [{ name: 'crm_call', label: 'V1', overrides, ...BASE_GATES, requiredPermissions: ['crm_admin', 'crm_auditor'] }] },
324+
};
325+
const artifacts: Artifacts = [{ type: 'action', item: { name: 'crm_call', label: 'Call', ...BASE_GATES, _packageId: BASE } }];
326+
const pairOf = (action: Record<string, unknown>) =>
327+
({ id: PAIR, version: '2.0.0', namespace: 'crm_custom', dependencies: deps(BASE), actions: [action] });
328+
329+
it('a v2 that relaxes the v1 tightening back to the base\'s own gates installs', async () => {
330+
const { res, saved } = await install(pairOf({ name: 'crm_call', label: 'V2', overrides, ...BASE_GATES }), [{ id: BASE }], armed, artifacts);
331+
expect(res.status, JSON.stringify(res.payload)).toBe(200);
332+
expect(saved).toEqual([['action', 'crm_call']]);
333+
});
334+
335+
it('a v2 that drops a gate the base\'s own action carries is still refused 403, and nothing is written', async () => {
336+
const wider = { name: 'crm_call', label: 'V2', overrides, ...BASE_GATES, ai: {} };
337+
const { res, saved, ledger } = await install(pairOf(wider), [{ id: BASE }], armed, artifacts);
338+
expect(res.status, JSON.stringify(res.payload)).toBe(403);
339+
expect(res.payload?.error?.code).toBe('NOT_OVERRIDABLE');
340+
expect(refusedOf(res)).toEqual([['action', 'crm_call', 'declared_action_widens_access']]);
341+
expect([saved, ledger]).toEqual([[], []]);
342+
});
343+
});

‎packages/cloud-connection/src/marketplace-install-local-plugin.ts‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -576,6 +576,8 @@ type NamespaceHoldingEngine = PackageWritabilityEngine & {
576576
type OverrideGateEngine = PackageWritabilityEngine & {
577577
registry?: PackageWritabilityEngine['registry'] & {
578578
getAllPackages?(): unknown;
579+
/** [#30542] An installed package's code artifact (`SchemaRegistry#getArtifactItem`). */
580+
getArtifactItem?(type: string, name: string, currentPackageId?: string): unknown;
579581
};
580582
};
581583
type OverrideGateLister = {
@@ -3691,6 +3693,20 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
36913693
return read;
36923694
};
36933695
const isBase = (id: string): boolean => delivered.has(id) || !isWritablePackage(ql, id);
3696+
// [#30542] An installed base's own item is its code artifact, the one
3697+
// the protocol door judges a declared action override against
3698+
// (`overrideWriteVerdict`'s `baseItemOf`). The listing is not: once
3699+
// the base's declared override is armed, it serves the override under
3700+
// the base's envelope, so a re-install of the pair was judged against
3701+
// its own previous version, and a v2 that relaxes a v1 tightening
3702+
// back to the base's own gates was refused. A registry that answers
3703+
// no artifact leaves the listed item.
3704+
const artifactOf = (item: OverrideWriteItem, base: string): unknown => {
3705+
const artifact = registry.getArtifactItem?.(item.type, item.name, base);
3706+
return isPlainObject(artifact) && artifact._packageId === base && listedIdentity(item.type, artifact) === item.identity
3707+
? artifact
3708+
: undefined;
3709+
};
36943710

36953711
const refusals: OverrideInstallRefusal[] = [];
36963712
for (const [packageId, pkg] of delivered) {
@@ -3703,7 +3719,10 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
37033719
const shipped = new Map<string, unknown>();
37043720
for (const base of bases) {
37053721
const held = (await heldBy(item.type, base)).get(item.identity);
3706-
if (held !== undefined) { shippers.push(base); shipped.set(base, held); }
3722+
if (held !== undefined) {
3723+
shippers.push(base);
3724+
shipped.set(base, (delivered.has(base) ? undefined : artifactOf(item, base)) ?? held);
3725+
}
37073726
}
37083727
const verdict = judgeOverrideWrite(
37093728
{ type: item.type, name: item.name, packageId, item: item.item },

‎packages/metadata-protocol/src/protocol-packaged-base-refusal.ts‎

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -766,6 +766,17 @@ export function overrideWriteIdentityOf(type: string, identity: string, item: un
766766
return member === undefined ? identity : `${identity}\u0000${member}`;
767767
}
768768

769+
/**
770+
* [#30542] The object an `action` item is keyed on under the override write
771+
* gate — its `objectName`, `''` when it names none: the first half of the
772+
* `<objectName>:<name>` identity {@link overrideWriteItemsOf} gives it, and
773+
* what {@link overrideWriteVerdict} matches a declared override's base on.
774+
*/
775+
function overrideWriteActionObjectOf(item: unknown): string {
776+
const objectName = isPlainRecord(item) ? item.objectName : undefined;
777+
return typeof objectName === 'string' ? objectName : '';
778+
}
779+
769780
/** One item written into one package — what the override write gate judges. */
770781
export interface OverrideWriteSubject {
771782
/** The metadata type, either spelling (folded here). */
@@ -1112,9 +1123,8 @@ export function overrideWriteItemsOf(body: Record<string, unknown>, at = ''): Ov
11121123
}
11131124
continue;
11141125
}
1115-
const identity = type === 'action' && typeof item.objectName === 'string' && item.objectName !== ''
1116-
? `${item.objectName}:${declaredName}`
1117-
: declaredName;
1126+
const object = type === 'action' ? overrideWriteActionObjectOf(item) : '';
1127+
const identity = object !== '' ? `${object}:${declaredName}` : declaredName;
11181128
out.push({ type, name: declaredName, identity: overrideWriteIdentityOf(type, identity, item), item, path });
11191129
}
11201130
}
@@ -1171,11 +1181,19 @@ export function overrideWriteVerdict(
11711181
if (packageId !== undefined && scope === undefined) return NOT_CONCERNED;
11721182
const dependencies = scope?.dependencies.filter((id) => id !== packageId);
11731183
const member = overrideWriteMemberOf(request.type, request.item);
1184+
// [#30542] A declared action override names its base's action by the
1185+
// identity `os validate` and the install door key it on,
1186+
// `<objectName>:<name>` ({@link overrideWriteItemsOf}): the base's action
1187+
// of that name on another object is no shipper of it, so an override that
1188+
// moves the action to another object is refused here as it is there.
1189+
const onItsObject = (a: unknown): boolean => !declaration.present
1190+
|| (PLURAL_TO_SINGULAR[request.type] ?? request.type) !== 'action'
1191+
|| overrideWriteActionObjectOf(a) === overrideWriteActionObjectOf(request.item);
11741192
const shippers = dependencies === undefined || dependencies.length === 0
11751193
? []
11761194
: Array.from(new Set(
11771195
this.shippedArtifactsOf(request.type, request.name, undefined, member)
1178-
.filter((a) => overrideWriteMemberOf(request.type, a) === member)
1196+
.filter((a) => overrideWriteMemberOf(request.type, a) === member && onItsObject(a))
11791197
.map((a) => (a as { _packageId?: unknown } | null | undefined)?._packageId)
11801198
// An override replaces an item of an INSTALLED, read-only base
11811199
// (ADR-0146 D1): a writable package's item is edited in place
@@ -1188,7 +1206,7 @@ export function overrideWriteVerdict(
11881206
// [#30065] The base's own item, the one a declared action override is
11891207
// judged against: the shipping artifact under that base's envelope.
11901208
const baseItemOf = (basePackageId: string): unknown => this.shippedArtifactsOf(request.type, request.name, undefined, member)
1191-
.find((a) => overrideWriteMemberOf(request.type, a) === member
1209+
.find((a) => overrideWriteMemberOf(request.type, a) === member && onItsObject(a)
11921210
&& (a as { _packageId?: unknown } | null | undefined)?._packageId === basePackageId);
11931211
return judgeOverrideWrite(request, { dependencies, shippers, baseItemOf });
11941212
}

0 commit comments

Comments
 (0)