Skip to content

Commit a9782b3

Browse files
fix(objectql,metadata,spec)!: an installed package's apps and objects never keep a self-written _packageId (#28745)
domain:server#2 · os-project-manager · session_0186vfjHHMsRXC9EUT9hTFMo <!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1X3KriyGEkCr9KSnUGjBS7sj)_ Fixes #28697 Clause-②: no (narrowing) Metadata-protocol: narrowed Skills-docs: none (no `skills/**` file teaches writing `_packageId` on an object or app; the one mention, `objectstack-data/references/examples-objects.md`, says REST returns it, which is unchanged) ## 维护者速读 - **改了什么**:包里的应用(apps)和对象(objects)如果自己写了别的包的 `_packageId`,现在一律被拒,和 #28534 拒其余 28 个集合用的是同一个判定函数。覆盖四处:引擎注册(含嵌套插件)、残余(residual)注册、元数据插件的包体注册(启动同步读的就是它写下的归属),以及 `defineStack` / `os validate` / `os build`。 - **为什么改**:实测(卡上的探针)一个通过 install-local 安装的包,把宿主的包 id 写进自己的 app,安装返回 200,这个 app 就被算成宿主的:按包筛选时列在宿主名下,停用该包时 app 仍在。 - **风险与回滚**:这是一次收窄(`Metadata-protocol: narrowed`),objectql、metadata、spec 升 minor。只影响手写了别的包 `_packageId` 的对象或应用,仓库里没有任何构建、导出或安装会写这个键。回滚就是 revert 这个 PR。 - **先看**:`packages/objectql/src/engine.ts` 的 `packageBodyObjectAndAppOwnerStampRefusal` 及其在 `registerApp` 开头的调用(在任何注册之前),`packages/metadata/src/plugin.ts` 的 `artifactItemOwnerStampRefusal`,`packages/spec/src/kernel/collection-item-owner-stamp.ts` 的集合清单。 - **两分钟验证**:`pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=1 test/install-local-object-app-owner-stamp.dogfood.test.ts`,预期 5 passed。 - **需要你做的**:无,契约评审 PASS 后由席位落地。 ## What changes One judge, now over `objects` and `apps` too. `judgeCollectionItemOwnerStamp` (`@objectstack/spec/kernel`) is still the only comparison. Every door below calls it, and no door spells a second one. - **Body seam (`ObjectQL.registerApp`).** The new `packageBodyObjectAndAppOwnerStampRefusal` (`engine.ts`) judges every `objects` entry (array and name-keyed map), every `apps` entry the loop registers, and the manifest when it registers as its own app. It goes through `collectionItemOwnerStampRefusal`, so the envelope (`VALIDATION_ERROR` / 400, `issues[]` at `_packageId`), the words and the registration-written-stamp exemption are #28534's. It runs **before** `installPackage`, so a refused body leaves no package record, object or app. Apps now register through `registerAppNotingStamp`, which records the stamp `registerItem` writes onto the caller's own object (the #28534 exemption). Without that, a module constant re-registered under a second package would be refused. - **Nested plugins (`registerPlugin`).** The same helper judges the plugin's objects and its plugin-as-app against the parent package. It runs before, and outside, the two `try` blocks that log a registry failure and continue. - **Residual door (`registerUnclaimedTopLevelObjects`, `objectql/src/plugin.ts`).** Every residual object is judged before any registers. Residual apps were already judged by `registerUnclaimedTopLevelItems`. With this change the residual door, the body seam and the build door agree on `apps` (#28695 S2). - **Build door (stack schema).** `OWNER_STAMP_JUDGED_COLLECTIONS` gains `objects` and `apps`, so `applyCollectionItemOwnerStampGate` raises an issue at `objects.N._packageId` / `apps.N._packageId`. The judge's docblock no longer lists either as unjudged, and it names the metadata door. The existing key-by-key test (`collection-item-owner-stamp-doors.test.ts`) holds the list equal to what `registerApp` refuses, and it still passes with both members added. - **Boot path.** `registerArtifactBodyCollections` (`@objectstack/metadata`) is reachable with a body no schema parse judged: a config boot's residual, through `AppPlugin` → `MetadataPlugin.registerUnclaimedTopLevel`. It now refuses an item of a judged collection whose body `_packageId` differs from the body's package, before `applyProtection` and the sink. That door writes the stamp that `ObjectQLPlugin.loadMetadataFromService` later files `app` / `view` / `flow` / `hook` under. `loadMetadataFromService` itself is unchanged apart from a comment: it reads items, not the body that registered them, so it has nothing to judge against. Ignoring the stamp there would flip `isArtifactBacked` for every package-shipped item it syncs. - **`objects`: refuse, as the order rules.** No first-party fixture relied on a foreign object stamp. The objectql (9911), metadata (924) and spec (23202) suites are green, and so is the full dogfood battery below. `registry.ts`'s `schema._packageId ?? packageId` therefore never reads a stamp a body wrote about another package. - **Versioned apps keep the exemption (S-a).** `registerAppNotingStamp(registry, app, ownerId, ownerVersion)` now calls `stampPackage` itself and reads the stamp BEFORE it, the order the collection and seed loops keep, so an app object re-registered after a versioned registration is recorded as registration-stamped, not refused. All three call sites (`registerApp` steps 3 and 4, `registerPlugin`'s plugin-as-app) use it. - **Nested plugins are judged before the parent registers (S-b).** `registerApp`'s pre-pass walks `manifest.plugins[]` (objects and plugin-as-app, against the parent id), so a nested plugin's foreign stamp is refused with no package record, object or app of the parent left behind. `registerPlugin` keeps its own pre-pass for the plugin it is handed. - **Changeset (S-c)** states the upgrade behaviour: a config boot carrying such a stamp fails to start with the judge's message; a package installed earlier that carries one is not loaded at the next start and is reported at `error`. - `origin/main` merged once (#28728 moved `engine.ts`; clean merge). ## Tests - `packages/qa/dogfood/test/install-local-object-app-owner-stamp.dogfood.test.ts` (new) boots the showcase with `MarketplaceInstallLocalPlugin` and pins the card's probe through `POST /api/v1/marketplace/install-local`: - an install whose app wears the host's id answers 422 `PLUGIN_REGISTER_FAILED` in the judge's words, and its (unstamped) object and its ledger entry are absent too; - the same for an install whose object wears the host's id. - Controls: an app and object stamping their own package, or nothing, install 200 and list under their own package; the host's apps list as at boot; the uninstall is keyed on the registering package and removes both. - `packages/objectql/src/package-object-app-owner-stamp.test.ts` (new) covers: - `apps[]`, the manifest-as-app, objects in array and map form, a nested plugin's object and plugin-as-app, and `null` / `''`, each with no package record left; - controls: absent and equal stamps, and the in-place stamp exemption for a re-registered app object; - the residual door for objects (new) and apps (already judged). - `packages/metadata/src/plugin-artifact-owner-stamp.test.ts` (new): the metadata door refuses a residual app, flow or view container with a foreign stamp before the sink stores anything. Controls: absent and equal stamps are stored under the owner, and `objectExtensions` (not judged by the build door) is stored as before. - `packages/metadata/src/metadata.test.ts`: the `@objectstack/spec/kernel` mock now spreads the real module and narrows only the type registry, because the artifact door reads the judge from that module. <details><summary>Red first: the dogfood and unit tests on the unfixed tree (every fixed source file checked out from origin/main, spec/metadata/objectql rebuilt, dist preflight absent)</summary> ``` spec dist judges apps: false × 1: an app stamping the host's package id is refused at the install, nothing of the package registered AssertionError: {"success":true,"data":{"manifestId":"com.dogfood.appstamp",…,"hotLoaded":true,…}}: expected 200 to be 422 × 2: an object stamping the host's package id is refused at the install, nothing of the package registered AssertionError: {"success":true,"data":{"manifestId":"com.dogfood.objstamp",…,"hotLoaded":true,…}}: expected 200 to be 422 × CONTROL: the host's own apps list as they did at boot, before and after the installs AssertionError: expected [ 'appstamp_app', 'showcase_app' ] to deeply equal [ 'showcase_app' ] Tests 3 failed | 2 passed (5) objectql package-object-app-owner-stamp.test.ts: 7 failed (every refusal case; the residual-app case passes: that door already judged apps) metadata plugin-artifact-owner-stamp.test.ts: 3 failed | 2 passed (5) restore: blob == HEAD for all 5 files, git diff HEAD empty ``` </details> <details><summary>Ablations (one judge at a time, `ablation-replace.mjs` + `ablation-dist-preflight.mjs`, each restored to the HEAD blob)</summary> ``` A1 apps judge dropped (registerApp scope apps:false, selfAsApp:false) · objectql rebuilt · dist marker present × 1: an app stamping the host's package id … × CONTROL: the host's own apps … Tests 2 failed | 3 passed (5) A2 objects judge dropped · objectql rebuilt · dist marker present (first attempt `false && objects` was a no-op reading: esbuild folded the marker away and the DTS build failed, so the preflight refused it; re-run with a marker that survives the build) × 2: an object stamping the host's package id … Tests 1 failed | 4 passed (5) A3 residual objects judge dropped · objectql rebuilt × a residual object wearing another package's id: refused, not served Tests 1 failed | 9 passed (10) A4 metadata door judge dropped (the boot path) × residual apps / flows / views entry … Tests 3 failed | 2 passed (5) restore: each blob == HEAD, git diff HEAD empty; rebuilt; preflight: every ablation marker absent, the fix present ``` </details> ## Acceptance notes - **Out of scope, measured on the engine seam:** a refusal raised in `registerApp`'s collection loop (#28534's owner stamp, the view-container and permission-set refusals) fires after the package record, objects and apps have registered, and they stay in the live registry. Probed on this branch with an unstamped object and app beside a foreign-stamped connector: refused 400, package record, object and app all still held. The objects and apps judged here run before anything registers, so this PR does not add to it. Not measured through a public door. - `loadMetadataFromService` still reads a stamp written by doors other than the metadata plugin's body registration: FS-loaded project files and stored `sys_metadata` rows. Rows echoing a body `_packageId` are seat 1's #28673. Neither path is an installed package's body. - #28695 S2 is settled here. S1, S3 and S4 stay on that card. ## Verification All local readings are on `498f5c70`. The pushed head `83e32c91` has the identical tree: the three commits were reworded (model-free trailer) with a tree-hash check. - **Package suites:** `pnpm --filter @objectstack/objectql test`: 514 files, 9911 tests passed. `@objectstack/metadata`: 66 files, 924 passed. `@objectstack/spec`: 784 passed, 1 skipped (23202 tests). `typecheck` for objectql, metadata and spec: exit 0. - **Full dogfood battery** (`pnpm --filter @objectstack/dogfood test` under the verify lock): `VERDICT command-exit 0`, Test Files 372 passed / 3 skipped (375), Tests 2999 passed / 28 skipped (3027). No failure. - **Derived gates:** `dispatch-gates --commands` derived 84 families; 83 exit 0. `pnpm check:dual-build-cjs-loads` exited 3 (PREREQUISITE NOT MET: it needs every package's `dist/`, a full `pnpm build`), so it is NOT MEASURED locally and left to CI. `dispatch-gates --ran`: 84 accounted, 83 run, 1 NOT-MEASURED, 0 UNRUN. - **Not run locally:** `test:repo` (the vitest `repo` projects), which CI's Test Core runs, and the repo-wide lint farm, which belongs to CI. Every other CI family is read from this PR's check-runs. **Round 2 (seat review should-fixes), head `8f56596d`:** - Red first on the pre-fold engine (`engine.ts` + `engine-package-registration.ts` from `7e796f0dd`): the nested-plugin case (parent's package record still held) and the versioned control (`version 1.0.0: expected Error: Drop _packageId from app ...`) red, 2 failed / 9 passed. Ablations on HEAD, each restored to the HEAD blob: dropping the `plugins[]` walk reds the nested case; reading the stamp after `stampPackage` reds the versioned control. - On `8f56596d`: objectql 516 files / 9935 tests, metadata 66 / 924, objectql typecheck exit 0; full dogfood Test Files 375 passed / 3 skipped, Tests 3039 passed / 28 skipped; derived gates 84, 83 exit 0, `check:dual-build-cjs-loads` NOT MEASURED (exit 3, needs a full build). --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5pevHhCDHNc3eWbkAg)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b357250 commit a9782b3

10 files changed

Lines changed: 735 additions & 17 deletions
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
"@objectstack/objectql": minor
3+
"@objectstack/metadata": minor
4+
"@objectstack/spec": minor
5+
---
6+
7+
fix(objectql,metadata,spec)!: a package's apps and objects never name their own owning package — the owner-stamp refusal covers `objects` and `apps` at every door
8+
9+
The owner-stamp judge (`judgeCollectionItemOwnerStamp`, `@objectstack/spec/kernel`) now covers the two collections a package body files through verbs of their own, `objects` and `apps`, which it did not judge before. Each refusal is `VALIDATION_ERROR` / 400 with `issues[]` at `_packageId`, in the judge's words:
10+
11+
- **`ObjectQL.registerApp`** refuses an `objects` entry (array or name-keyed map), an `apps` entry, or the manifest registered as its own app, whose body carries a `_packageId` other than the registering package. It refuses before the body registers anything, so no package record, object or app is left behind. A nested plugin's objects and plugin-as-app are judged against the parent package the same way.
12+
- **The `manifest` service's residual registration** refuses a residual object's stamp before any residual object registers. It already refused a residual app's.
13+
- **The metadata plugin's body registration** (`@objectstack/metadata`: the artifact door and a config boot's residual) refuses a judged collection's item before it stamps and stores it. The engine's boot sync files every `app`, `view`, `flow` and `hook` it reads back under that stamp.
14+
- **The stack schema** (`defineStack`, `os validate`, `os build`) judges `objects` and `apps` too (`OWNER_STAMP_JUDGED_COLLECTIONS` gains both), so the build door refuses what the boot refuses.
15+
16+
Clause-②: no (narrowing) — package registration, the metadata plugin's body registration and the stack schema refuse an object or app whose body `_packageId` names a package other than the one that registers it, which they accepted before with the body's stamp kept. Nothing is widened.
17+
18+
Metadata-protocol: narrowed (an object's or app's `_packageId`, declared by `MetadataProtectionFields`, may no longer name a package other than the one that registers it)
19+
20+
<!-- adr-0087: not-required (no-migration-prescription) No key is added, removed or renamed, and no value a legitimate producer writes is refused: the registration is the only writer of `_packageId` (`os build` writes none, the package export strips every underscore key, the artifact loader stamps with the very id the registration keys on), so a foreign stamp on an object or app exists only in a body somebody wrote by hand to claim another owner. A D2 conversion could not tell such a stamp from an agreeing one without the registration that judges it, and the refusal names the item, both ids and the remedy. -->
21+
22+
**BREAKING** accept-set narrowing at package registration and the build door (`@objectstack/objectql`, `@objectstack/metadata`, `@objectstack/spec`), shipped as `minor`.
23+
24+
- **Before.** An installed manifest `com.acme.app` whose `apps[0]` carried `_packageId: 'com.acme.host'` registered the app filed under the host. `GET /api/v1/meta/app?package=com.acme.host` listed it, `?package=com.acme.app` listed nothing, and disabling `com.acme.app` kept serving the app while its objects answered 404. An object's body stamp was kept on its definition.
25+
- **Now.** The registration throws before anything of the package registers. Through `POST /api/v1/marketplace/install-local`, an inline install answers `422 PLUGIN_REGISTER_FAILED` with that message and persists nothing. `defineStack` / `os validate` report the same stamp as an issue at `objects.N._packageId` or `apps.N._packageId`. On upgrade, a config boot whose stack carries such a stamp fails to start with the judge's message, and a package installed earlier that carries one is not loaded at the next start and is reported at `error`.
26+
- **Unchanged.** An absent `_packageId`, or one equal to the registering package, registers as before. An app object that an earlier registration stamped in place, such as a module constant handed to a second registration, registers as it always has. `objectExtensions` are not judged.
27+
- **Who sees it.** Only someone who hand-writes `_packageId` on an object or app naming another package. No build, export or install writes one.
28+
29+
**The remedy.** Remove `_packageId` from the object or app. The package an item belongs to is the one that registers it, stamped by the registration.

‎packages/metadata/src/metadata.test.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -548,7 +548,11 @@ describe('MetadataPlugin', () => {
548548
});
549549

550550
// Mock the spec kernel import
551-
vi.mock('@objectstack/spec/kernel', () => ({
551+
// The rest of the module is the real one: the artifact door judges a body's
552+
// `_packageId` through `judgeCollectionItemOwnerStamp` over
553+
// `OWNER_STAMP_JUDGED_COLLECTIONS` (#28697), and only the type registry is narrowed here.
554+
vi.mock('@objectstack/spec/kernel', async (importOriginal) => ({
555+
...(await importOriginal<typeof import('@objectstack/spec/kernel')>()),
552556
DEFAULT_METADATA_TYPE_REGISTRY: [
553557
{ type: 'object', label: 'Object', filePatterns: ['**/*.object.ts'], supportsOverlay: true, allowRuntimeCreate: false, supportsVersioning: true, loadOrder: 10, domain: 'data' },
554558
{ type: 'view', label: 'View', filePatterns: ['**/*.view.ts'], supportsOverlay: true, allowRuntimeCreate: true, supportsVersioning: false, loadOrder: 50, domain: 'ui' },
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#28697] The metadata plugin's body registration (`registerArtifactBodyCollections`,
5+
* reached here through `MetadataPlugin.registerUnclaimedTopLevel`, the call a
6+
* config boot makes with a stack no schema parse judged) refuses an item whose
7+
* body already wears a `_packageId` other than the package it registers it
8+
* under — by the one judge (`judgeCollectionItemOwnerStamp`), over the build
9+
* door's collections, before the sink stores anything.
10+
*
11+
* Why it matters: `applyProtection` stamps an unset key only, so the body's own
12+
* stamp used to be what this door stored, and the engine's boot sync
13+
* (`ObjectQLPlugin.loadMetadataFromService`) files every `app` / `view` /
14+
* `flow` / `hook` it reads back under that stamp.
15+
*/
16+
17+
import { describe, it, expect } from 'vitest';
18+
import { judgeCollectionItemOwnerStamp } from '@objectstack/spec/kernel';
19+
import { MetadataPlugin } from './plugin.js';
20+
21+
const STACK = 'com.probe.stack';
22+
const OTHER = 'com.probe.a';
23+
const body = (id: string, namespace: string) => ({ id, name: namespace, namespace, version: '1.0.0', type: 'app' });
24+
25+
/** A two-body config stack whose top-level `extra` no body declares: the residual, owned by `STACK`. */
26+
const withResidual = (extra: Record<string, unknown>) => ({
27+
manifest: body(STACK, 'probestack'),
28+
packages: [{ manifest: body(OTHER, 'probea') }, { manifest: body('com.probe.b', 'probeb') }],
29+
...extra,
30+
});
31+
32+
async function registerResidual(stack: unknown): Promise<{ error: any; stored: Array<{ type: string; name: string; item: any }> }> {
33+
const stored: Array<{ type: string; name: string; item: any }> = [];
34+
let error: any;
35+
try {
36+
await MetadataPlugin.registerUnclaimedTopLevel(
37+
{ register: async (type, name, item) => { stored.push({ type, name, item }); }, warn: () => undefined },
38+
stack,
39+
{ door: 'config', label: 'probe' },
40+
);
41+
} catch (e) {
42+
error = e;
43+
}
44+
return { error, stored };
45+
}
46+
47+
const app = (stamp?: unknown) => ({ name: 'probe_app', label: 'Probe', ...(stamp === undefined ? {} : { _packageId: stamp }) });
48+
const flow = (stamp?: unknown) => ({ name: 'probe_flow', label: 'Probe', type: 'autolaunched', ...(stamp === undefined ? {} : { _packageId: stamp }) });
49+
const container = (stamp?: unknown) => ({
50+
object: 'probe_thing',
51+
list: { type: 'grid', columns: [{ field: 'name' }] },
52+
...(stamp === undefined ? {} : { _packageId: stamp }),
53+
});
54+
55+
describe('[#28697] the metadata door refuses a body stamp naming another package, before it stores anything', () => {
56+
it.each([
57+
['apps', 'app', 'probe_app', app],
58+
['flows', 'flow', 'probe_flow', flow],
59+
['views', 'view', 'probe_thing', container],
60+
] as const)('a residual %s entry wearing another package\'s id: VALIDATION_ERROR / 400 at `_packageId`, nothing stored', async (key, type, name, make) => {
61+
const { error, stored } = await registerResidual(withResidual({ [key]: [make(OTHER)] }));
62+
expect(error, 'the registration must be refused').toBeInstanceOf(Error);
63+
expect(error).toMatchObject({ code: 'VALIDATION_ERROR', status: 400, httpStatus: 400 });
64+
expect(error.issues).toEqual([{ path: '_packageId', code: 'custom', message: expect.any(String) }]);
65+
expect(error.message).toBe(judgeCollectionItemOwnerStamp(make(OTHER), type, name, STACK, 'register')!.message);
66+
expect(stored).toEqual([]);
67+
});
68+
69+
it('CONTROL: an absent stamp and a stamp equal to the owner are stored, stamped with the owner', async () => {
70+
for (const stamp of [undefined, STACK]) {
71+
const { error, stored } = await registerResidual(withResidual({ apps: [app(stamp)], flows: [flow(stamp)] }));
72+
expect(error, `stamp ${String(stamp)}`).toBeUndefined();
73+
expect(stored.map((s) => [s.type, s.name, s.item._packageId])).toEqual([
74+
['app', 'probe_app', STACK],
75+
['flow', 'probe_flow', STACK],
76+
]);
77+
}
78+
});
79+
80+
it('CONTROL: a collection the build door does not judge (`objectExtensions`) is stored as before', async () => {
81+
const ext = { name: 'probe_ext', extend: 'probe_thing', fields: {}, _packageId: OTHER };
82+
const { error } = await registerResidual(withResidual({ objectExtensions: [ext] }));
83+
expect(error).toBeUndefined();
84+
});
85+
});

‎packages/metadata/src/plugin.ts‎

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { Plugin, PluginContext } from '@objectstack/core';
1414
import { resolveArtifactPackageOrder, artifactPackageId } from '@objectstack/core';
1515
import { NodeMetadataManager } from './node-metadata-manager.js';
1616
import { MemoryLoader } from './loaders/memory-loader.js';
17-
import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel';
17+
import { DEFAULT_METADATA_TYPE_REGISTRY, judgeCollectionItemOwnerStamp, OWNER_STAMP_JUDGED_COLLECTIONS } from '@objectstack/spec/kernel';
1818
import type { MetadataPluginConfig } from '@objectstack/spec/kernel';
1919
import { applyProtection } from '@objectstack/spec/shared';
2020
import {
@@ -459,6 +459,43 @@ export function unclaimedTopLevel(stack: unknown): UnclaimedTopLevel | undefined
459459
* the single-package branch D7 pins.
460460
* @returns How many items this body registered.
461461
*/
462+
/**
463+
* [#28697] The owner-stamp refusal at this door: an item of a judged
464+
* collection ({@link OWNER_STAMP_JUDGED_COLLECTIONS}) whose body already wears
465+
* a `_packageId` other than the body's own package, judged by the one judge
466+
* (`judgeCollectionItemOwnerStamp`, `@objectstack/spec/kernel`) in the
467+
* registration door's words and envelope (`VALIDATION_ERROR` / 400, `issues[]`
468+
* at `_packageId`, the envelope the engine's body seam throws).
469+
*
470+
* Why here: `applyProtection` below stamps an unset key only, so a body's own
471+
* stamp used to be what this door stored, and the engine's boot sync
472+
* (`ObjectQLPlugin.loadMetadataFromService`) files every `app` / `view` /
473+
* `flow` / `hook` it reads back under that stamp. A stack the schema parsed
474+
* was judged at the build door first; a body no parse judged (a config boot's
475+
* residual from a `strict: false` stack) was not, so this door judges every
476+
* body it registers. The collections are the build door's, so neither door
477+
* refuses what the other accepts.
478+
*/
479+
function artifactItemOwnerStampRefusal(
480+
field: string,
481+
metaType: string,
482+
name: string,
483+
item: unknown,
484+
packageId: string | undefined,
485+
): (Error & { code: 'VALIDATION_ERROR'; status: 400; httpStatus: 400; issues: Array<{ path: string; message: string; code: string }> }) | undefined {
486+
if (!(OWNER_STAMP_JUDGED_COLLECTIONS as readonly string[]).includes(field)) return undefined;
487+
const refusal = judgeCollectionItemOwnerStamp(item, metaType, name, packageId, 'register');
488+
if (!refusal) return undefined;
489+
const err = new Error(refusal.message) as Error & {
490+
code: 'VALIDATION_ERROR'; status: 400; httpStatus: 400; issues: Array<{ path: string; message: string; code: string }>;
491+
};
492+
err.code = 'VALIDATION_ERROR';
493+
err.status = 400;
494+
err.httpStatus = 400;
495+
err.issues = [{ path: '_packageId', message: refusal.issueMessage, code: 'custom' }];
496+
return err;
497+
}
498+
462499
async function registerArtifactBodyCollections(
463500
sink: ArtifactRegistrationSink,
464501
body: Record<string, unknown>,
@@ -496,6 +533,8 @@ async function registerArtifactBodyCollections(
496533
// write; row 1 is unchanged for every type.
497534
const nameRefusal = viewContainerNameRefusal(item, sourceLabel, packageId);
498535
if (nameRefusal) throw nameRefusal;
536+
const ownerStamp = artifactItemOwnerStampRefusal(field, metaType, viewObject, item, packageId);
537+
if (ownerStamp) throw ownerStamp;
499538
applyProtection(item as any, {
500539
packageId: packageId,
501540
packageVersion: packageVersion,
@@ -537,6 +576,8 @@ async function registerArtifactBodyCollections(
537576
// it adds there is exactly the leading `object` term this path
538577
// was missing.
539578
const name = slot.name;
579+
const ownerStamp = artifactItemOwnerStampRefusal(field, metaType, name, item, packageId);
580+
if (ownerStamp) throw ownerStamp;
540581
// ADR-0010 §3.7 — translate the author-facing
541582
// `protection` block into the private `_lock` envelope
542583
// and stamp package provenance in one call. Strips the

‎packages/objectql/src/engine-package-registration.ts‎

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,11 @@
3737
* `registerMetadataCollections`, `stampObjectPackage`). One home each: they
3838
* stay declared in `engine.ts`, exported in place, and are imported here,
3939
* never copied. Nothing re-exports them from the package entry, so the
40-
* published surface does not move.
40+
* published surface does not move. [#28697] Two more follow the same rule:
41+
* `packageBodyObjectAndAppOwnerStampRefusal` and `registerAppNotingStamp`
42+
* (`registerApp` and `registerPlugin`); the plugin-as-app now reaches
43+
* `stampPackage` through `registerAppNotingStamp`, so this module no longer
44+
* imports `stampPackage` itself.
4145
*
4246
* That import is a run-time module cycle (`engine.ts` imports this module,
4347
* this module imports `engine.ts`). Nothing here runs at module evaluation,
@@ -50,7 +54,11 @@
5054
import type { ServiceObject } from '@objectstack/spec/data';
5155
import { applyArtifactForwardConversions, resolveDeclaredRange } from '@objectstack/metadata-core';
5256
import { computeFQN } from './registry.js';
53-
import { stampObjectPackage, stampPackage } from './engine.js';
57+
import {
58+
packageBodyObjectAndAppOwnerStampRefusal,
59+
registerAppNotingStamp,
60+
stampObjectPackage,
61+
} from './engine.js';
5462
import type { ObjectQL } from './engine.js';
5563

5664
/**
@@ -143,6 +151,18 @@ export function registerPlugin(
143151
// contribute objects UNDER the parent's ownership.
144152
const ownerId = parentId;
145153

154+
// [#28697] The owner-stamp refusal for the objects and the plugin-as-app
155+
// below, before anything of the plugin registers and outside the two
156+
// `try` blocks, which log a registry failure and go on: a body stamp is
157+
// refused as the collection seam refuses it, not logged past.
158+
{
159+
const refusal = packageBodyObjectAndAppOwnerStampRefusal(plugin, ownerId, {
160+
apps: false,
161+
selfAsApp: Boolean(plugin.name && plugin.navigation),
162+
});
163+
if (refusal) throw refusal;
164+
}
165+
146166
// Register objects (supports both Array and Map formats)
147167
if (plugin.objects) {
148168
try {
@@ -172,7 +192,7 @@ export function registerPlugin(
172192
if (plugin.name && plugin.navigation) {
173193
try {
174194
const resolved = pluginNamespace ? this.resolveNavObjectNames(plugin, pluginNamespace) : plugin;
175-
this._registry.registerApp(stampPackage(resolved, ownerId, parentVersion), ownerId);
195+
registerAppNotingStamp(this._registry, resolved, ownerId, parentVersion);
176196
this.logger.debug('Registered plugin-as-app', { app: plugin.name, from: pluginName });
177197
} catch (err: any) {
178198
this.logger.warn('Failed to register plugin as app', { pluginName, error: err.message });

0 commit comments

Comments
 (0)