One artifact, N packages: let a release bundle carry co-owning packages so a product can be split into modules without renaming objects #14122

Description

@hotlong

从下游应用提出(steedos-labs/hotcrm-heimaoobjectstack-ai/hotcrm)。全部结论实测于 @objectstack/*17.2.0 运行实例,非推断。

修订 1:补入 §5.6「安装时对象名唯一性检查」——放宽命名空间闸的安全前提,初版遗漏;并新增 §6 兼容性一节(对存量客户元数据应用的影响)。


一、业务需求

1.1 标准产品 + 客户定制,今天靠整仓复制

我们有标准 CRM(hotcrm),客户会在它上面做定制。今天的模式是把整个仓库复制一份随便改——黑猫(hotcrm-heimao)就是 hotcrm 的 fork。

fork 税是实测的,不是担心:把 @objectstack/* 从 17.1.0 升到 17.2.0,两个仓库各做一遍,同样的坑(sharing 播种、StackBlitz 双锁文件门)各踩一遍,知识靠人手搬。黑猫对标准 CRM 的改动其实约 95% 是增量——碰到标准对象的 6 个文件是 +1,000/−15 行,15 行删除全是替换而非删能力。也就是说:绝大部分本可以是"扩展",却因为没有边界而变成了"分叉"。

1.2 产品自身的模块也在扩张

不只是客户定制。hotcrm 想在销售模块上扩 CPQ;黑猫加了"订单与毛利测算"(12 个对象 + 8 个 hook)。今天这些都堆在同一个 src/,和标准 CRM 的对象平铺在一起。

1.3 由此产生的三个可度量的业务痛点

① 维护者看不清自己的系统。 Studio 是元数据管理界面,作用域是软件包。黑猫这一个包里:

Studio 分区内容是否分组
数据30 个对象❌ 平铺
自动化29 条 flow❌ 平铺,且标签截断
界面导航树✅ 有分组
权限30 对象 × 9 列 CRUD × 6 个权限集❌ 平铺

自动化那一栏里,29 条 flow 有两条都截断成 Case Escalation…,肉眼分不出是哪两条。权限矩阵是一张 30 行 × 9 列的网格,把 客户/联系人/商机运费标准/等级政策/工厂成本 混在一起——给一个销售主管配权限,要在这张表里滚完 30 行。

「界面」分区之所以有分组,不是因为它做得更好,而是因为它渲染的恰好是唯一被作者写下来的那份分组app.navigation 的 group 节点)。对象、flow、权限集在 spec 里都没有任何分组键:ObjectSchemaBase 的顶层只有 name / label / pluralLabel / description / icon / isSystemFlowSchema 只有 name / label / description

② 上下文预算没有模块边界。 产品定位是"一个 CRM 销售模块能完整装进 AI 的上下文窗口",并由 scripts/check-source-token-ratchet.mjs 机械守着。黑猫当前:

business semantics 112,746 / 115,000 headroom 2,254
interaction layer 44,495 / 48,000 headroom 3,505
authored total 178,417 / 180,000 headroom 1,583 ← 卡这里

没有模块边界,就没有分模块的预算——所有模块一起顶到同一个天花板,谁也说不清是谁把预算吃掉了。

③ 商业边界不存在。 CPQ 希望能单独售卖,但它和销售模块在同一个包里,没有可售的单元。


二、为什么三条显而易见的路都走不通(实测)

2.1 给对象/flow 加 module 标签 —— 预算不允许

ratchet 计入 190 个授权文件。每处 module: 'order_margin', 约 25 字符 ≈ 6 tokens,190 处 ≈ 1,100–1,200 tokens,占总量剩余额度的 70–75%。而抬 ceiling 是维护者的 floor item,不能由 agent 动。

一个纯分组键吃掉四分之三的剩余预算,性价比不成立。

2.2 拆成各自命名空间的独立包 —— 要给每个对象改名

SchemaRegistry.installPackage()(ADR-0048 §3.2):

if(manifest.namespace&&!isShareableNamespace(manifest.namespace)){constconflictOwner=getNamespaceOwners(ns).find(o=>o!==manifest.id);if(conflictOwner)thrownewNamespaceConflictError(...)}

判据是**"不是同一个包 id"**,豁免只有 RESERVED_NAMESPACES = {base, system}sys。加上 defineStackvalidateNamespacePrefix 要求 object.name === ${namespace}_${shortName}——N 个包就是 N 个命名空间,就是把 crm_account 改成 sales_account。表名、API 路径、公式、筛选器、集成、客户存的视图全断。

ADR-0048 §5 自己把这条列为已接受的非目标:"Namespace rename-on-install is an explicit non-goal for now (deep rewrite of every object name, cross-reference, and formula)."

2.3 用现成的 composeStacks 合并编译 —— 子包身份会丢

composeStacks 的关键选项:

manifest: z.union([z.enum(['first','last']),z.number().int().min(0)]).default('last')

它在 N 份 manifest 里挑一份,扔掉其余的。 现成的合并机制是"压平",不是"保留"。走完整个重构会落回一个 30 对象的平包——收益恰好在它该出现的地方蒸发。


三、最终方案:一个发布物,物内 N 个包

编译按包,产物仍是一个 JSON,JSON 里保留 N 份 manifest,装载时按依赖拓扑序注册 N 个包。

HotCRM 发布物(一个 JSON,一个版本号,应用市场照旧发一个文件)
├── app.objectstack.hotcrm type: app ← 唯一的消费者单元,拥有应用与导航
├── app.objectstack.hotcrm.cpq type: module ← 共用 crm 命名空间,navigationContributions 注入导航
└── app.objectstack.hotcrm.order type: module ← 同上

3.1 为什么这样不违反 ADR-0019

ADR-0019 D2 已经分好层:app 是消费者单元,plugin/module/driver 是"内部贡献——.app bundle 里的框架,随 App 一起装,消费者从不单独浏览或安装"。D4 更是原文写着 "An App declares and owns a set of namespaces"——是 set,不是单个

所以消费者仍然装一个、开一个、卸一个,D1/D2/D3 原样成立。validateSingleApp 也不咬——它只约束 type: app

不能跨的线:这一层必须停在控制面。一旦让"发布物"成为消费者要浏览、要安装、要卸载的东西,那就是 ADR-0019 D3 点名禁掉的 suite,苹果那段论证原样适用。

3.2 共用命名空间的所有权证明就是"同一个物"

同一个物里来的包,是同一个发布者原子交付的。物本身就是共同所有权的声明,因此安装闸的判据从"同一个包 id"改成"同一个物内"即可,manifest不需要新增 owner 字段

(owner 字段只在 CPQ 哪天单独成物、单独售卖时才必须——那时它要向外声明"我和 HotCRM 同属一个发布者"。ADR-0048 的 2026-08-08 addendum D2 已经把发布侧的保留权定给了 publisher,届时两侧对齐即可。本 issue 把这件事推迟,不前置。)


四、实测支撑:注册层本来就是多包的

已经支持、不用改的:

事实位置
registerApp(manifest)按包函数:用该 manifest 的 id/namespace 给每个对象打 'own'objectql/src/engine.ts:4745
命名空间存储本来就是多所有者(namespaceRegistry 是 namespace → packageId 集合的 Map),getNamespaceOwners() 返回数组objectql/src/registry.ts:1456
卸载已经按包清,不是按命名空间扫registry.unregisterObjectsByPackage(packageId)
对象已有逐条 owner:contributors[{ packageId, ownership: 'own' | 'extend' }]同上
这个装机今天就注册着 23 个包,全走同一条路径/api/v1/meta/package

编写侧今天就通过: 子包声明 namespace: 'crm'、定义 crm_* 对象、用 navigationContributions 注入 crm_enterprise,三种 type 全部 defineStack ACCEPTED(plugin / module / app)。唯一的闸在 installPackage

跨包能力矩阵(逐条实测):

跨包做什么结果
lookup / 关系字段指向外包对象✅ ACCEPTED
navigationContributions 注入外包 app 且指向外包对象✅ ACCEPTED
本包 app 的 navigation 指向外包对象App 'x' navigation references object 'core_account' which is not defined in objects.
hook 挂到外包对象上Hook 'h' references object 'core_account' which is not defined in objects.

后两条的含义很具体:导航必须走 contributions(平台自己六个插件往 setup 注入就是这个形状);拆的缝必须顺着 hook 归属切。黑猫这条缝是干净的——它新增的 8 个 hook 全部挂在它自己新增的对象上。

产物现状:os buildos compile 的别名,产出一个 JSON(默认 dist/objectstack.json)。黑猫已构建的那份:manifest单个 dictapp.objectstack.hotcrm, ns crm, v3.0.0),objects 30 个平铺,2.6 MB,manifest.engines.protocol"^17.2.0"


五、要改的(应用市场链路不动)

不管物里装几个包,市场发的还是一个文件——分发、下载、上架、安装入口的传输形态全部不变。

#改动规模
1产物 schema:manifest 单数 → packages: [...] 列表。必须做成新增可选键、两种都读(见 §6)唯一实质的一处
2装载处循环:objectql/src/plugin.ts:405ql.registerApp(manifest) → 遍历一个循环
3composeStacks 增加保留模式(今天 'first'/'last'/index 是刻意的挑选语义),默认值不变
4installPackage 命名空间闸认"同一个物内的共同所有者"一行判据
5Studio 包选择器列出 project 域的 module 包(今天只显示 app.objectstack.hotcrm,22 个系统插件被过滤)objectui 侧,另开
6安装时逐对象名唯一性检查 —— 放宽 §5.4 那道闸的安全前提,见下小,但不可省

5.6 为什么对象名唯一性检查不是可选项

命名空间独占今天在代理另一件事。ADR-0048 原文:

objects dodge collisions because their names are namespace-prefixed (crm_account) and map to physical tables; a clash fails loudly at the DB

也就是说,"两个包不能共用命名空间"这道闸,顺带保证了"不会有两个包定义同名对象"。一旦按 §5.4 放宽它而不补显式检查,同一个物里两个包都定义 crm_account 时:

  • 安装期不报错
  • 拖到 DB 层才炸(重复建表),或者按驱动实现不同,可能一个包静默覆盖另一个包的表定义 —— 这是唯一会真正伤到客户数据的形态

所以 §5.4 与 §5.6 必须同批落地,不能拆开发布。


六、兼容性:对存量客户元数据应用的影响

结论:不拆模块的存量客户零影响——前提是 §5.1 做成增量而非替换。

6.1 核心原因:本方案不改名

这既是它相对 §2.2 的根本优势,也正是兼容性的答案:

  • 对象名不变 → 表名、REST 路径、公式、筛选器、客户存的视图全部不变
  • org 级元数据覆盖(ADR-0005 overlay)按对象名/字段名键控 → 不变
  • 不拆模块的包仍是一个包,_packageId 不变
  • Studio 路由 /_console/studio/<packageId>/... 不变
  • 客户不需要做任何数据迁移

6.2 逐条

改动对存量客户
§5.1 产物 schema前提:新增可选键、两种都读(有 packages 就遍历,没有就把 manifest 当单元素列表)。这样存量产物行为逐位不变
§5.2 装载循环内部,无感
§5.3 composeStacks新增可选项,默认仍是 'last' → 现有调用方无感
§5.4 闸放宽放宽从不打破原本能跑的东西 —— 但必须与 §5.6 同批
§5.5 Studio 选择器UI 增量
§5.6 对象名唯一性新增的拒绝:只会拒掉今天本就会在 DB 层炸的配置,且拒得更早、更清楚

6.3 前向兼容已有机制,不用新建

产物里已有 manifest.engines.protocol(黑猫那份是 "^17.2.0")。新格式产物声明新的 protocol range,旧运行时会干净地拒绝而不是错误解析。

6.4 建议的验收条件

存量单 manifest 产物经新装载路径后,注册结果逐位相同(包记录、对象 FQN、_packageId 标记、命名空间所有者集合)。

这条应当写成测试,而不是靠 review 判断。


七、待决策

① 这条路走不走。 这是主问题。替代方案是 §2 的三条,各自的否决理由已实测列出。

② 拓扑序是必须项,不是优化项。 一个包用 defineObjectExtension 扩另一个包的对象,必须在被扩的包之后注册。今天跨物安装靠 manifest.dependenciesresolvePluginOrder 定序;N 个包塞进一个物之后,装载处必须拓扑排序,不能直接 for 数组。这是这套改动里唯一会静默出错的地方——顺序错了不报错,只是扩展没生效。请把它作为验收条件,不要留给实现者判断。

③ 一个物一个版本号,取舍认不认。 好处:版本矩阵直接消失,客户不可能装出 core 3.2 + cpq 1.4 这种没人测过的组合,defineObjectExtension 也不会因为被扩的包单独升级而悬空。代价:不能单独热修一个模块,任何一处修都要重发整物。对内部模块这个取舍是对的;对要单独售卖的模块不成立,那种应当独立成物。

④ 产物体积。 黑猫 30 个对象已经 2.6 MB。模块继续往一个 JSON 里加,市场传输和启动解析都会涨。不是拦路虎,但该在 schema 定下来的时候就决定要不要分段加载,而不是等它到 20 MB 再回头改格式。

⑤ 确认 owner 字段推迟。 本方案不加 manifest 的 owner/publisher 字段,理由见 §3.2。如果维护者认为应当与 ADR-0048 addendum D2 同批落地,请在此说明——那会让 §5 多一项。


八、下游可以先做什么(不阻塞本 issue)

黑猫和 hotcrm 的交付链路在我们手里,可以先按包拆源码结构、锁步发布(N 个包永远同版本、整批安装),等产物 schema 落地再切过去,源码结构不用返工。

如实标注这条中间路的缺陷:锁步是约定,不是闸——没有任何机制阻止装成错配的一对。对自建交付可控,一旦上架市场就不成立。这正是本 issue 要解决的部分。

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions

      , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
       blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
      }
      } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
      })();
      (function(){
      try {
      var __m = "github.com";
      var __re = new RegExp('^' + "github\\.com" + '
      
      Skip to content

      One artifact, N packages: let a release bundle carry co-owning packages so a product can be split into modules without renaming objects #14122

      Description

      @hotlong

      从下游应用提出(steedos-labs/hotcrm-heimaoobjectstack-ai/hotcrm)。全部结论实测于 @objectstack/*17.2.0 运行实例,非推断。

      修订 1:补入 §5.6「安装时对象名唯一性检查」——放宽命名空间闸的安全前提,初版遗漏;并新增 §6 兼容性一节(对存量客户元数据应用的影响)。


      一、业务需求

      1.1 标准产品 + 客户定制,今天靠整仓复制

      我们有标准 CRM(hotcrm),客户会在它上面做定制。今天的模式是把整个仓库复制一份随便改——黑猫(hotcrm-heimao)就是 hotcrm 的 fork。

      fork 税是实测的,不是担心:把 @objectstack/* 从 17.1.0 升到 17.2.0,两个仓库各做一遍,同样的坑(sharing 播种、StackBlitz 双锁文件门)各踩一遍,知识靠人手搬。黑猫对标准 CRM 的改动其实约 95% 是增量——碰到标准对象的 6 个文件是 +1,000/−15 行,15 行删除全是替换而非删能力。也就是说:绝大部分本可以是"扩展",却因为没有边界而变成了"分叉"。

      1.2 产品自身的模块也在扩张

      不只是客户定制。hotcrm 想在销售模块上扩 CPQ;黑猫加了"订单与毛利测算"(12 个对象 + 8 个 hook)。今天这些都堆在同一个 src/,和标准 CRM 的对象平铺在一起。

      1.3 由此产生的三个可度量的业务痛点

      ① 维护者看不清自己的系统。 Studio 是元数据管理界面,作用域是软件包。黑猫这一个包里:

      Studio 分区内容是否分组
      数据30 个对象❌ 平铺
      自动化29 条 flow❌ 平铺,且标签截断
      界面导航树✅ 有分组
      权限30 对象 × 9 列 CRUD × 6 个权限集❌ 平铺

      自动化那一栏里,29 条 flow 有两条都截断成 Case Escalation…,肉眼分不出是哪两条。权限矩阵是一张 30 行 × 9 列的网格,把 客户/联系人/商机运费标准/等级政策/工厂成本 混在一起——给一个销售主管配权限,要在这张表里滚完 30 行。

      「界面」分区之所以有分组,不是因为它做得更好,而是因为它渲染的恰好是唯一被作者写下来的那份分组app.navigation 的 group 节点)。对象、flow、权限集在 spec 里都没有任何分组键:ObjectSchemaBase 的顶层只有 name / label / pluralLabel / description / icon / isSystemFlowSchema 只有 name / label / description

      ② 上下文预算没有模块边界。 产品定位是"一个 CRM 销售模块能完整装进 AI 的上下文窗口",并由 scripts/check-source-token-ratchet.mjs 机械守着。黑猫当前:

      business semantics 112,746 / 115,000 headroom 2,254
      interaction layer 44,495 / 48,000 headroom 3,505
      authored total 178,417 / 180,000 headroom 1,583 ← 卡这里
      

      没有模块边界,就没有分模块的预算——所有模块一起顶到同一个天花板,谁也说不清是谁把预算吃掉了。

      ③ 商业边界不存在。 CPQ 希望能单独售卖,但它和销售模块在同一个包里,没有可售的单元。


      二、为什么三条显而易见的路都走不通(实测)

      2.1 给对象/flow 加 module 标签 —— 预算不允许

      ratchet 计入 190 个授权文件。每处 module: 'order_margin', 约 25 字符 ≈ 6 tokens,190 处 ≈ 1,100–1,200 tokens,占总量剩余额度的 70–75%。而抬 ceiling 是维护者的 floor item,不能由 agent 动。

      一个纯分组键吃掉四分之三的剩余预算,性价比不成立。

      2.2 拆成各自命名空间的独立包 —— 要给每个对象改名

      SchemaRegistry.installPackage()(ADR-0048 §3.2):

      if(manifest.namespace&&!isShareableNamespace(manifest.namespace)){constconflictOwner=getNamespaceOwners(ns).find(o=>o!==manifest.id);if(conflictOwner)thrownewNamespaceConflictError(...)}

      判据是**"不是同一个包 id"**,豁免只有 RESERVED_NAMESPACES = {base, system}sys。加上 defineStackvalidateNamespacePrefix 要求 object.name === ${namespace}_${shortName}——N 个包就是 N 个命名空间,就是把 crm_account 改成 sales_account。表名、API 路径、公式、筛选器、集成、客户存的视图全断。

      ADR-0048 §5 自己把这条列为已接受的非目标:"Namespace rename-on-install is an explicit non-goal for now (deep rewrite of every object name, cross-reference, and formula)."

      2.3 用现成的 composeStacks 合并编译 —— 子包身份会丢

      composeStacks 的关键选项:

      manifest: z.union([z.enum(['first','last']),z.number().int().min(0)]).default('last')

      它在 N 份 manifest 里挑一份,扔掉其余的。 现成的合并机制是"压平",不是"保留"。走完整个重构会落回一个 30 对象的平包——收益恰好在它该出现的地方蒸发。


      三、最终方案:一个发布物,物内 N 个包

      编译按包,产物仍是一个 JSON,JSON 里保留 N 份 manifest,装载时按依赖拓扑序注册 N 个包。

      HotCRM 发布物(一个 JSON,一个版本号,应用市场照旧发一个文件)
      ├── app.objectstack.hotcrm type: app ← 唯一的消费者单元,拥有应用与导航
      ├── app.objectstack.hotcrm.cpq type: module ← 共用 crm 命名空间,navigationContributions 注入导航
      └── app.objectstack.hotcrm.order type: module ← 同上
      

      3.1 为什么这样不违反 ADR-0019

      ADR-0019 D2 已经分好层:app 是消费者单元,plugin/module/driver 是"内部贡献——.app bundle 里的框架,随 App 一起装,消费者从不单独浏览或安装"。D4 更是原文写着 "An App declares and owns a set of namespaces"——是 set,不是单个

      所以消费者仍然装一个、开一个、卸一个,D1/D2/D3 原样成立。validateSingleApp 也不咬——它只约束 type: app

      不能跨的线:这一层必须停在控制面。一旦让"发布物"成为消费者要浏览、要安装、要卸载的东西,那就是 ADR-0019 D3 点名禁掉的 suite,苹果那段论证原样适用。

      3.2 共用命名空间的所有权证明就是"同一个物"

      同一个物里来的包,是同一个发布者原子交付的。物本身就是共同所有权的声明,因此安装闸的判据从"同一个包 id"改成"同一个物内"即可,manifest不需要新增 owner 字段

      (owner 字段只在 CPQ 哪天单独成物、单独售卖时才必须——那时它要向外声明"我和 HotCRM 同属一个发布者"。ADR-0048 的 2026-08-08 addendum D2 已经把发布侧的保留权定给了 publisher,届时两侧对齐即可。本 issue 把这件事推迟,不前置。)


      四、实测支撑:注册层本来就是多包的

      已经支持、不用改的:

      事实位置
      registerApp(manifest)按包函数:用该 manifest 的 id/namespace 给每个对象打 'own'objectql/src/engine.ts:4745
      命名空间存储本来就是多所有者(namespaceRegistry 是 namespace → packageId 集合的 Map),getNamespaceOwners() 返回数组objectql/src/registry.ts:1456
      卸载已经按包清,不是按命名空间扫registry.unregisterObjectsByPackage(packageId)
      对象已有逐条 owner:contributors[{ packageId, ownership: 'own' | 'extend' }]同上
      这个装机今天就注册着 23 个包,全走同一条路径/api/v1/meta/package

      编写侧今天就通过: 子包声明 namespace: 'crm'、定义 crm_* 对象、用 navigationContributions 注入 crm_enterprise,三种 type 全部 defineStack ACCEPTED(plugin / module / app)。唯一的闸在 installPackage

      跨包能力矩阵(逐条实测):

      跨包做什么结果
      lookup / 关系字段指向外包对象✅ ACCEPTED
      navigationContributions 注入外包 app 且指向外包对象✅ ACCEPTED
      本包 app 的 navigation 指向外包对象App 'x' navigation references object 'core_account' which is not defined in objects.
      hook 挂到外包对象上Hook 'h' references object 'core_account' which is not defined in objects.

      后两条的含义很具体:导航必须走 contributions(平台自己六个插件往 setup 注入就是这个形状);拆的缝必须顺着 hook 归属切。黑猫这条缝是干净的——它新增的 8 个 hook 全部挂在它自己新增的对象上。

      产物现状:os buildos compile 的别名,产出一个 JSON(默认 dist/objectstack.json)。黑猫已构建的那份:manifest单个 dictapp.objectstack.hotcrm, ns crm, v3.0.0),objects 30 个平铺,2.6 MB,manifest.engines.protocol"^17.2.0"


      五、要改的(应用市场链路不动)

      不管物里装几个包,市场发的还是一个文件——分发、下载、上架、安装入口的传输形态全部不变。

      #改动规模
      1产物 schema:manifest 单数 → packages: [...] 列表。必须做成新增可选键、两种都读(见 §6)唯一实质的一处
      2装载处循环:objectql/src/plugin.ts:405ql.registerApp(manifest) → 遍历一个循环
      3composeStacks 增加保留模式(今天 'first'/'last'/index 是刻意的挑选语义),默认值不变
      4installPackage 命名空间闸认"同一个物内的共同所有者"一行判据
      5Studio 包选择器列出 project 域的 module 包(今天只显示 app.objectstack.hotcrm,22 个系统插件被过滤)objectui 侧,另开
      6安装时逐对象名唯一性检查 —— 放宽 §5.4 那道闸的安全前提,见下小,但不可省

      5.6 为什么对象名唯一性检查不是可选项

      命名空间独占今天在代理另一件事。ADR-0048 原文:

      objects dodge collisions because their names are namespace-prefixed (crm_account) and map to physical tables; a clash fails loudly at the DB

      也就是说,"两个包不能共用命名空间"这道闸,顺带保证了"不会有两个包定义同名对象"。一旦按 §5.4 放宽它而不补显式检查,同一个物里两个包都定义 crm_account 时:

      • 安装期不报错
      • 拖到 DB 层才炸(重复建表),或者按驱动实现不同,可能一个包静默覆盖另一个包的表定义 —— 这是唯一会真正伤到客户数据的形态

      所以 §5.4 与 §5.6 必须同批落地,不能拆开发布。


      六、兼容性:对存量客户元数据应用的影响

      结论:不拆模块的存量客户零影响——前提是 §5.1 做成增量而非替换。

      6.1 核心原因:本方案不改名

      这既是它相对 §2.2 的根本优势,也正是兼容性的答案:

      • 对象名不变 → 表名、REST 路径、公式、筛选器、客户存的视图全部不变
      • org 级元数据覆盖(ADR-0005 overlay)按对象名/字段名键控 → 不变
      • 不拆模块的包仍是一个包,_packageId 不变
      • Studio 路由 /_console/studio/<packageId>/... 不变
      • 客户不需要做任何数据迁移

      6.2 逐条

      改动对存量客户
      §5.1 产物 schema前提:新增可选键、两种都读(有 packages 就遍历,没有就把 manifest 当单元素列表)。这样存量产物行为逐位不变
      §5.2 装载循环内部,无感
      §5.3 composeStacks新增可选项,默认仍是 'last' → 现有调用方无感
      §5.4 闸放宽放宽从不打破原本能跑的东西 —— 但必须与 §5.6 同批
      §5.5 Studio 选择器UI 增量
      §5.6 对象名唯一性新增的拒绝:只会拒掉今天本就会在 DB 层炸的配置,且拒得更早、更清楚

      6.3 前向兼容已有机制,不用新建

      产物里已有 manifest.engines.protocol(黑猫那份是 "^17.2.0")。新格式产物声明新的 protocol range,旧运行时会干净地拒绝而不是错误解析。

      6.4 建议的验收条件

      存量单 manifest 产物经新装载路径后,注册结果逐位相同(包记录、对象 FQN、_packageId 标记、命名空间所有者集合)。

      这条应当写成测试,而不是靠 review 判断。


      七、待决策

      ① 这条路走不走。 这是主问题。替代方案是 §2 的三条,各自的否决理由已实测列出。

      ② 拓扑序是必须项,不是优化项。 一个包用 defineObjectExtension 扩另一个包的对象,必须在被扩的包之后注册。今天跨物安装靠 manifest.dependenciesresolvePluginOrder 定序;N 个包塞进一个物之后,装载处必须拓扑排序,不能直接 for 数组。这是这套改动里唯一会静默出错的地方——顺序错了不报错,只是扩展没生效。请把它作为验收条件,不要留给实现者判断。

      ③ 一个物一个版本号,取舍认不认。 好处:版本矩阵直接消失,客户不可能装出 core 3.2 + cpq 1.4 这种没人测过的组合,defineObjectExtension 也不会因为被扩的包单独升级而悬空。代价:不能单独热修一个模块,任何一处修都要重发整物。对内部模块这个取舍是对的;对要单独售卖的模块不成立,那种应当独立成物。

      ④ 产物体积。 黑猫 30 个对象已经 2.6 MB。模块继续往一个 JSON 里加,市场传输和启动解析都会涨。不是拦路虎,但该在 schema 定下来的时候就决定要不要分段加载,而不是等它到 20 MB 再回头改格式。

      ⑤ 确认 owner 字段推迟。 本方案不加 manifest 的 owner/publisher 字段,理由见 §3.2。如果维护者认为应当与 ADR-0048 addendum D2 同批落地,请在此说明——那会让 §5 多一项。


      八、下游可以先做什么(不阻塞本 issue)

      黑猫和 hotcrm 的交付链路在我们手里,可以先按包拆源码结构、锁步发布(N 个包永远同版本、整批安装),等产物 schema 落地再切过去,源码结构不用返工。

      如实标注这条中间路的缺陷:锁步是约定,不是闸——没有任何机制阻止装成错配的一对。对自建交付可控,一旦上架市场就不成立。这正是本 issue 要解决的部分。

      Metadata

      Metadata

      Assignees

      No one assigned

        Type

        No type

        Projects

        No projects

          Milestone

          No milestone

          Relationships

          None yet

          Development

          No branches or pull requests

          Issue actions

          , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
          Skip to content

          One artifact, N packages: let a release bundle carry co-owning packages so a product can be split into modules without renaming objects #14122

          Description

          @hotlong

          从下游应用提出(steedos-labs/hotcrm-heimaoobjectstack-ai/hotcrm)。全部结论实测于 @objectstack/*17.2.0 运行实例,非推断。

          修订 1:补入 §5.6「安装时对象名唯一性检查」——放宽命名空间闸的安全前提,初版遗漏;并新增 §6 兼容性一节(对存量客户元数据应用的影响)。


          一、业务需求

          1.1 标准产品 + 客户定制,今天靠整仓复制

          我们有标准 CRM(hotcrm),客户会在它上面做定制。今天的模式是把整个仓库复制一份随便改——黑猫(hotcrm-heimao)就是 hotcrm 的 fork。

          fork 税是实测的,不是担心:把 @objectstack/* 从 17.1.0 升到 17.2.0,两个仓库各做一遍,同样的坑(sharing 播种、StackBlitz 双锁文件门)各踩一遍,知识靠人手搬。黑猫对标准 CRM 的改动其实约 95% 是增量——碰到标准对象的 6 个文件是 +1,000/−15 行,15 行删除全是替换而非删能力。也就是说:绝大部分本可以是"扩展",却因为没有边界而变成了"分叉"。

          1.2 产品自身的模块也在扩张

          不只是客户定制。hotcrm 想在销售模块上扩 CPQ;黑猫加了"订单与毛利测算"(12 个对象 + 8 个 hook)。今天这些都堆在同一个 src/,和标准 CRM 的对象平铺在一起。

          1.3 由此产生的三个可度量的业务痛点

          ① 维护者看不清自己的系统。 Studio 是元数据管理界面,作用域是软件包。黑猫这一个包里:

          Studio 分区内容是否分组
          数据30 个对象❌ 平铺
          自动化29 条 flow❌ 平铺,且标签截断
          界面导航树✅ 有分组
          权限30 对象 × 9 列 CRUD × 6 个权限集❌ 平铺

          自动化那一栏里,29 条 flow 有两条都截断成 Case Escalation…,肉眼分不出是哪两条。权限矩阵是一张 30 行 × 9 列的网格,把 客户/联系人/商机运费标准/等级政策/工厂成本 混在一起——给一个销售主管配权限,要在这张表里滚完 30 行。

          「界面」分区之所以有分组,不是因为它做得更好,而是因为它渲染的恰好是唯一被作者写下来的那份分组app.navigation 的 group 节点)。对象、flow、权限集在 spec 里都没有任何分组键:ObjectSchemaBase 的顶层只有 name / label / pluralLabel / description / icon / isSystemFlowSchema 只有 name / label / description

          ② 上下文预算没有模块边界。 产品定位是"一个 CRM 销售模块能完整装进 AI 的上下文窗口",并由 scripts/check-source-token-ratchet.mjs 机械守着。黑猫当前:

          business semantics 112,746 / 115,000 headroom 2,254
          interaction layer 44,495 / 48,000 headroom 3,505
          authored total 178,417 / 180,000 headroom 1,583 ← 卡这里
          

          没有模块边界,就没有分模块的预算——所有模块一起顶到同一个天花板,谁也说不清是谁把预算吃掉了。

          ③ 商业边界不存在。 CPQ 希望能单独售卖,但它和销售模块在同一个包里,没有可售的单元。


          二、为什么三条显而易见的路都走不通(实测)

          2.1 给对象/flow 加 module 标签 —— 预算不允许

          ratchet 计入 190 个授权文件。每处 module: 'order_margin', 约 25 字符 ≈ 6 tokens,190 处 ≈ 1,100–1,200 tokens,占总量剩余额度的 70–75%。而抬 ceiling 是维护者的 floor item,不能由 agent 动。

          一个纯分组键吃掉四分之三的剩余预算,性价比不成立。

          2.2 拆成各自命名空间的独立包 —— 要给每个对象改名

          SchemaRegistry.installPackage()(ADR-0048 §3.2):

          if(manifest.namespace&&!isShareableNamespace(manifest.namespace)){constconflictOwner=getNamespaceOwners(ns).find(o=>o!==manifest.id);if(conflictOwner)thrownewNamespaceConflictError(...)}

          判据是**"不是同一个包 id"**,豁免只有 RESERVED_NAMESPACES = {base, system}sys。加上 defineStackvalidateNamespacePrefix 要求 object.name === ${namespace}_${shortName}——N 个包就是 N 个命名空间,就是把 crm_account 改成 sales_account。表名、API 路径、公式、筛选器、集成、客户存的视图全断。

          ADR-0048 §5 自己把这条列为已接受的非目标:"Namespace rename-on-install is an explicit non-goal for now (deep rewrite of every object name, cross-reference, and formula)."

          2.3 用现成的 composeStacks 合并编译 —— 子包身份会丢

          composeStacks 的关键选项:

          manifest: z.union([z.enum(['first','last']),z.number().int().min(0)]).default('last')

          它在 N 份 manifest 里挑一份,扔掉其余的。 现成的合并机制是"压平",不是"保留"。走完整个重构会落回一个 30 对象的平包——收益恰好在它该出现的地方蒸发。


          三、最终方案:一个发布物,物内 N 个包

          编译按包,产物仍是一个 JSON,JSON 里保留 N 份 manifest,装载时按依赖拓扑序注册 N 个包。

          HotCRM 发布物(一个 JSON,一个版本号,应用市场照旧发一个文件)
          ├── app.objectstack.hotcrm type: app ← 唯一的消费者单元,拥有应用与导航
          ├── app.objectstack.hotcrm.cpq type: module ← 共用 crm 命名空间,navigationContributions 注入导航
          └── app.objectstack.hotcrm.order type: module ← 同上
          

          3.1 为什么这样不违反 ADR-0019

          ADR-0019 D2 已经分好层:app 是消费者单元,plugin/module/driver 是"内部贡献——.app bundle 里的框架,随 App 一起装,消费者从不单独浏览或安装"。D4 更是原文写着 "An App declares and owns a set of namespaces"——是 set,不是单个

          所以消费者仍然装一个、开一个、卸一个,D1/D2/D3 原样成立。validateSingleApp 也不咬——它只约束 type: app

          不能跨的线:这一层必须停在控制面。一旦让"发布物"成为消费者要浏览、要安装、要卸载的东西,那就是 ADR-0019 D3 点名禁掉的 suite,苹果那段论证原样适用。

          3.2 共用命名空间的所有权证明就是"同一个物"

          同一个物里来的包,是同一个发布者原子交付的。物本身就是共同所有权的声明,因此安装闸的判据从"同一个包 id"改成"同一个物内"即可,manifest不需要新增 owner 字段

          (owner 字段只在 CPQ 哪天单独成物、单独售卖时才必须——那时它要向外声明"我和 HotCRM 同属一个发布者"。ADR-0048 的 2026-08-08 addendum D2 已经把发布侧的保留权定给了 publisher,届时两侧对齐即可。本 issue 把这件事推迟,不前置。)


          四、实测支撑:注册层本来就是多包的

          已经支持、不用改的:

          事实位置
          registerApp(manifest)按包函数:用该 manifest 的 id/namespace 给每个对象打 'own'objectql/src/engine.ts:4745
          命名空间存储本来就是多所有者(namespaceRegistry 是 namespace → packageId 集合的 Map),getNamespaceOwners() 返回数组objectql/src/registry.ts:1456
          卸载已经按包清,不是按命名空间扫registry.unregisterObjectsByPackage(packageId)
          对象已有逐条 owner:contributors[{ packageId, ownership: 'own' | 'extend' }]同上
          这个装机今天就注册着 23 个包,全走同一条路径/api/v1/meta/package

          编写侧今天就通过: 子包声明 namespace: 'crm'、定义 crm_* 对象、用 navigationContributions 注入 crm_enterprise,三种 type 全部 defineStack ACCEPTED(plugin / module / app)。唯一的闸在 installPackage

          跨包能力矩阵(逐条实测):

          跨包做什么结果
          lookup / 关系字段指向外包对象✅ ACCEPTED
          navigationContributions 注入外包 app 且指向外包对象✅ ACCEPTED
          本包 app 的 navigation 指向外包对象App 'x' navigation references object 'core_account' which is not defined in objects.
          hook 挂到外包对象上Hook 'h' references object 'core_account' which is not defined in objects.

          后两条的含义很具体:导航必须走 contributions(平台自己六个插件往 setup 注入就是这个形状);拆的缝必须顺着 hook 归属切。黑猫这条缝是干净的——它新增的 8 个 hook 全部挂在它自己新增的对象上。

          产物现状:os buildos compile 的别名,产出一个 JSON(默认 dist/objectstack.json)。黑猫已构建的那份:manifest单个 dictapp.objectstack.hotcrm, ns crm, v3.0.0),objects 30 个平铺,2.6 MB,manifest.engines.protocol"^17.2.0"


          五、要改的(应用市场链路不动)

          不管物里装几个包,市场发的还是一个文件——分发、下载、上架、安装入口的传输形态全部不变。

          #改动规模
          1产物 schema:manifest 单数 → packages: [...] 列表。必须做成新增可选键、两种都读(见 §6)唯一实质的一处
          2装载处循环:objectql/src/plugin.ts:405ql.registerApp(manifest) → 遍历一个循环
          3composeStacks 增加保留模式(今天 'first'/'last'/index 是刻意的挑选语义),默认值不变
          4installPackage 命名空间闸认"同一个物内的共同所有者"一行判据
          5Studio 包选择器列出 project 域的 module 包(今天只显示 app.objectstack.hotcrm,22 个系统插件被过滤)objectui 侧,另开
          6安装时逐对象名唯一性检查 —— 放宽 §5.4 那道闸的安全前提,见下小,但不可省

          5.6 为什么对象名唯一性检查不是可选项

          命名空间独占今天在代理另一件事。ADR-0048 原文:

          objects dodge collisions because their names are namespace-prefixed (crm_account) and map to physical tables; a clash fails loudly at the DB

          也就是说,"两个包不能共用命名空间"这道闸,顺带保证了"不会有两个包定义同名对象"。一旦按 §5.4 放宽它而不补显式检查,同一个物里两个包都定义 crm_account 时:

          • 安装期不报错
          • 拖到 DB 层才炸(重复建表),或者按驱动实现不同,可能一个包静默覆盖另一个包的表定义 —— 这是唯一会真正伤到客户数据的形态

          所以 §5.4 与 §5.6 必须同批落地,不能拆开发布。


          六、兼容性:对存量客户元数据应用的影响

          结论:不拆模块的存量客户零影响——前提是 §5.1 做成增量而非替换。

          6.1 核心原因:本方案不改名

          这既是它相对 §2.2 的根本优势,也正是兼容性的答案:

          • 对象名不变 → 表名、REST 路径、公式、筛选器、客户存的视图全部不变
          • org 级元数据覆盖(ADR-0005 overlay)按对象名/字段名键控 → 不变
          • 不拆模块的包仍是一个包,_packageId 不变
          • Studio 路由 /_console/studio/<packageId>/... 不变
          • 客户不需要做任何数据迁移

          6.2 逐条

          改动对存量客户
          §5.1 产物 schema前提:新增可选键、两种都读(有 packages 就遍历,没有就把 manifest 当单元素列表)。这样存量产物行为逐位不变
          §5.2 装载循环内部,无感
          §5.3 composeStacks新增可选项,默认仍是 'last' → 现有调用方无感
          §5.4 闸放宽放宽从不打破原本能跑的东西 —— 但必须与 §5.6 同批
          §5.5 Studio 选择器UI 增量
          §5.6 对象名唯一性新增的拒绝:只会拒掉今天本就会在 DB 层炸的配置,且拒得更早、更清楚

          6.3 前向兼容已有机制,不用新建

          产物里已有 manifest.engines.protocol(黑猫那份是 "^17.2.0")。新格式产物声明新的 protocol range,旧运行时会干净地拒绝而不是错误解析。

          6.4 建议的验收条件

          存量单 manifest 产物经新装载路径后,注册结果逐位相同(包记录、对象 FQN、_packageId 标记、命名空间所有者集合)。

          这条应当写成测试,而不是靠 review 判断。


          七、待决策

          ① 这条路走不走。 这是主问题。替代方案是 §2 的三条,各自的否决理由已实测列出。

          ② 拓扑序是必须项,不是优化项。 一个包用 defineObjectExtension 扩另一个包的对象,必须在被扩的包之后注册。今天跨物安装靠 manifest.dependenciesresolvePluginOrder 定序;N 个包塞进一个物之后,装载处必须拓扑排序,不能直接 for 数组。这是这套改动里唯一会静默出错的地方——顺序错了不报错,只是扩展没生效。请把它作为验收条件,不要留给实现者判断。

          ③ 一个物一个版本号,取舍认不认。 好处:版本矩阵直接消失,客户不可能装出 core 3.2 + cpq 1.4 这种没人测过的组合,defineObjectExtension 也不会因为被扩的包单独升级而悬空。代价:不能单独热修一个模块,任何一处修都要重发整物。对内部模块这个取舍是对的;对要单独售卖的模块不成立,那种应当独立成物。

          ④ 产物体积。 黑猫 30 个对象已经 2.6 MB。模块继续往一个 JSON 里加,市场传输和启动解析都会涨。不是拦路虎,但该在 schema 定下来的时候就决定要不要分段加载,而不是等它到 20 MB 再回头改格式。

          ⑤ 确认 owner 字段推迟。 本方案不加 manifest 的 owner/publisher 字段,理由见 §3.2。如果维护者认为应当与 ADR-0048 addendum D2 同批落地,请在此说明——那会让 §5 多一项。


          八、下游可以先做什么(不阻塞本 issue)

          黑猫和 hotcrm 的交付链路在我们手里,可以先按包拆源码结构、锁步发布(N 个包永远同版本、整批安装),等产物 schema 落地再切过去,源码结构不用返工。

          如实标注这条中间路的缺陷:锁步是约定,不是闸——没有任何机制阻止装成错配的一对。对自建交付可控,一旦上架市场就不成立。这正是本 issue 要解决的部分。

          Metadata

          Metadata

          Assignees

          No one assigned

            Type

            No type

            Projects

            No projects

              Milestone

              No milestone

              Relationships

              None yet

              Development

              No branches or pull requests

              Issue actions

              , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
              Skip to content

              One artifact, N packages: let a release bundle carry co-owning packages so a product can be split into modules without renaming objects #14122

              Description

              @hotlong

              从下游应用提出(steedos-labs/hotcrm-heimaoobjectstack-ai/hotcrm)。全部结论实测于 @objectstack/*17.2.0 运行实例,非推断。

              修订 1:补入 §5.6「安装时对象名唯一性检查」——放宽命名空间闸的安全前提,初版遗漏;并新增 §6 兼容性一节(对存量客户元数据应用的影响)。


              一、业务需求

              1.1 标准产品 + 客户定制,今天靠整仓复制

              我们有标准 CRM(hotcrm),客户会在它上面做定制。今天的模式是把整个仓库复制一份随便改——黑猫(hotcrm-heimao)就是 hotcrm 的 fork。

              fork 税是实测的,不是担心:把 @objectstack/* 从 17.1.0 升到 17.2.0,两个仓库各做一遍,同样的坑(sharing 播种、StackBlitz 双锁文件门)各踩一遍,知识靠人手搬。黑猫对标准 CRM 的改动其实约 95% 是增量——碰到标准对象的 6 个文件是 +1,000/−15 行,15 行删除全是替换而非删能力。也就是说:绝大部分本可以是"扩展",却因为没有边界而变成了"分叉"。

              1.2 产品自身的模块也在扩张

              不只是客户定制。hotcrm 想在销售模块上扩 CPQ;黑猫加了"订单与毛利测算"(12 个对象 + 8 个 hook)。今天这些都堆在同一个 src/,和标准 CRM 的对象平铺在一起。

              1.3 由此产生的三个可度量的业务痛点

              ① 维护者看不清自己的系统。 Studio 是元数据管理界面,作用域是软件包。黑猫这一个包里:

              Studio 分区内容是否分组
              数据30 个对象❌ 平铺
              自动化29 条 flow❌ 平铺,且标签截断
              界面导航树✅ 有分组
              权限30 对象 × 9 列 CRUD × 6 个权限集❌ 平铺

              自动化那一栏里,29 条 flow 有两条都截断成 Case Escalation…,肉眼分不出是哪两条。权限矩阵是一张 30 行 × 9 列的网格,把 客户/联系人/商机运费标准/等级政策/工厂成本 混在一起——给一个销售主管配权限,要在这张表里滚完 30 行。

              「界面」分区之所以有分组,不是因为它做得更好,而是因为它渲染的恰好是唯一被作者写下来的那份分组app.navigation 的 group 节点)。对象、flow、权限集在 spec 里都没有任何分组键:ObjectSchemaBase 的顶层只有 name / label / pluralLabel / description / icon / isSystemFlowSchema 只有 name / label / description

              ② 上下文预算没有模块边界。 产品定位是"一个 CRM 销售模块能完整装进 AI 的上下文窗口",并由 scripts/check-source-token-ratchet.mjs 机械守着。黑猫当前:

              business semantics 112,746 / 115,000 headroom 2,254
              interaction layer 44,495 / 48,000 headroom 3,505
              authored total 178,417 / 180,000 headroom 1,583 ← 卡这里
              

              没有模块边界,就没有分模块的预算——所有模块一起顶到同一个天花板,谁也说不清是谁把预算吃掉了。

              ③ 商业边界不存在。 CPQ 希望能单独售卖,但它和销售模块在同一个包里,没有可售的单元。


              二、为什么三条显而易见的路都走不通(实测)

              2.1 给对象/flow 加 module 标签 —— 预算不允许

              ratchet 计入 190 个授权文件。每处 module: 'order_margin', 约 25 字符 ≈ 6 tokens,190 处 ≈ 1,100–1,200 tokens,占总量剩余额度的 70–75%。而抬 ceiling 是维护者的 floor item,不能由 agent 动。

              一个纯分组键吃掉四分之三的剩余预算,性价比不成立。

              2.2 拆成各自命名空间的独立包 —— 要给每个对象改名

              SchemaRegistry.installPackage()(ADR-0048 §3.2):

              if(manifest.namespace&&!isShareableNamespace(manifest.namespace)){constconflictOwner=getNamespaceOwners(ns).find(o=>o!==manifest.id);if(conflictOwner)thrownewNamespaceConflictError(...)}

              判据是**"不是同一个包 id"**,豁免只有 RESERVED_NAMESPACES = {base, system}sys。加上 defineStackvalidateNamespacePrefix 要求 object.name === ${namespace}_${shortName}——N 个包就是 N 个命名空间,就是把 crm_account 改成 sales_account。表名、API 路径、公式、筛选器、集成、客户存的视图全断。

              ADR-0048 §5 自己把这条列为已接受的非目标:"Namespace rename-on-install is an explicit non-goal for now (deep rewrite of every object name, cross-reference, and formula)."

              2.3 用现成的 composeStacks 合并编译 —— 子包身份会丢

              composeStacks 的关键选项:

              manifest: z.union([z.enum(['first','last']),z.number().int().min(0)]).default('last')

              它在 N 份 manifest 里挑一份,扔掉其余的。 现成的合并机制是"压平",不是"保留"。走完整个重构会落回一个 30 对象的平包——收益恰好在它该出现的地方蒸发。


              三、最终方案:一个发布物,物内 N 个包

              编译按包,产物仍是一个 JSON,JSON 里保留 N 份 manifest,装载时按依赖拓扑序注册 N 个包。

              HotCRM 发布物(一个 JSON,一个版本号,应用市场照旧发一个文件)
              ├── app.objectstack.hotcrm type: app ← 唯一的消费者单元,拥有应用与导航
              ├── app.objectstack.hotcrm.cpq type: module ← 共用 crm 命名空间,navigationContributions 注入导航
              └── app.objectstack.hotcrm.order type: module ← 同上
              

              3.1 为什么这样不违反 ADR-0019

              ADR-0019 D2 已经分好层:app 是消费者单元,plugin/module/driver 是"内部贡献——.app bundle 里的框架,随 App 一起装,消费者从不单独浏览或安装"。D4 更是原文写着 "An App declares and owns a set of namespaces"——是 set,不是单个

              所以消费者仍然装一个、开一个、卸一个,D1/D2/D3 原样成立。validateSingleApp 也不咬——它只约束 type: app

              不能跨的线:这一层必须停在控制面。一旦让"发布物"成为消费者要浏览、要安装、要卸载的东西,那就是 ADR-0019 D3 点名禁掉的 suite,苹果那段论证原样适用。

              3.2 共用命名空间的所有权证明就是"同一个物"

              同一个物里来的包,是同一个发布者原子交付的。物本身就是共同所有权的声明,因此安装闸的判据从"同一个包 id"改成"同一个物内"即可,manifest不需要新增 owner 字段

              (owner 字段只在 CPQ 哪天单独成物、单独售卖时才必须——那时它要向外声明"我和 HotCRM 同属一个发布者"。ADR-0048 的 2026-08-08 addendum D2 已经把发布侧的保留权定给了 publisher,届时两侧对齐即可。本 issue 把这件事推迟,不前置。)


              四、实测支撑:注册层本来就是多包的

              已经支持、不用改的:

              事实位置
              registerApp(manifest)按包函数:用该 manifest 的 id/namespace 给每个对象打 'own'objectql/src/engine.ts:4745
              命名空间存储本来就是多所有者(namespaceRegistry 是 namespace → packageId 集合的 Map),getNamespaceOwners() 返回数组objectql/src/registry.ts:1456
              卸载已经按包清,不是按命名空间扫registry.unregisterObjectsByPackage(packageId)
              对象已有逐条 owner:contributors[{ packageId, ownership: 'own' | 'extend' }]同上
              这个装机今天就注册着 23 个包,全走同一条路径/api/v1/meta/package

              编写侧今天就通过: 子包声明 namespace: 'crm'、定义 crm_* 对象、用 navigationContributions 注入 crm_enterprise,三种 type 全部 defineStack ACCEPTED(plugin / module / app)。唯一的闸在 installPackage

              跨包能力矩阵(逐条实测):

              跨包做什么结果
              lookup / 关系字段指向外包对象✅ ACCEPTED
              navigationContributions 注入外包 app 且指向外包对象✅ ACCEPTED
              本包 app 的 navigation 指向外包对象App 'x' navigation references object 'core_account' which is not defined in objects.
              hook 挂到外包对象上Hook 'h' references object 'core_account' which is not defined in objects.

              后两条的含义很具体:导航必须走 contributions(平台自己六个插件往 setup 注入就是这个形状);拆的缝必须顺着 hook 归属切。黑猫这条缝是干净的——它新增的 8 个 hook 全部挂在它自己新增的对象上。

              产物现状:os buildos compile 的别名,产出一个 JSON(默认 dist/objectstack.json)。黑猫已构建的那份:manifest单个 dictapp.objectstack.hotcrm, ns crm, v3.0.0),objects 30 个平铺,2.6 MB,manifest.engines.protocol"^17.2.0"


              五、要改的(应用市场链路不动)

              不管物里装几个包,市场发的还是一个文件——分发、下载、上架、安装入口的传输形态全部不变。

              #改动规模
              1产物 schema:manifest 单数 → packages: [...] 列表。必须做成新增可选键、两种都读(见 §6)唯一实质的一处
              2装载处循环:objectql/src/plugin.ts:405ql.registerApp(manifest) → 遍历一个循环
              3composeStacks 增加保留模式(今天 'first'/'last'/index 是刻意的挑选语义),默认值不变
              4installPackage 命名空间闸认"同一个物内的共同所有者"一行判据
              5Studio 包选择器列出 project 域的 module 包(今天只显示 app.objectstack.hotcrm,22 个系统插件被过滤)objectui 侧,另开
              6安装时逐对象名唯一性检查 —— 放宽 §5.4 那道闸的安全前提,见下小,但不可省

              5.6 为什么对象名唯一性检查不是可选项

              命名空间独占今天在代理另一件事。ADR-0048 原文:

              objects dodge collisions because their names are namespace-prefixed (crm_account) and map to physical tables; a clash fails loudly at the DB

              也就是说,"两个包不能共用命名空间"这道闸,顺带保证了"不会有两个包定义同名对象"。一旦按 §5.4 放宽它而不补显式检查,同一个物里两个包都定义 crm_account 时:

              • 安装期不报错
              • 拖到 DB 层才炸(重复建表),或者按驱动实现不同,可能一个包静默覆盖另一个包的表定义 —— 这是唯一会真正伤到客户数据的形态

              所以 §5.4 与 §5.6 必须同批落地,不能拆开发布。


              六、兼容性:对存量客户元数据应用的影响

              结论:不拆模块的存量客户零影响——前提是 §5.1 做成增量而非替换。

              6.1 核心原因:本方案不改名

              这既是它相对 §2.2 的根本优势,也正是兼容性的答案:

              • 对象名不变 → 表名、REST 路径、公式、筛选器、客户存的视图全部不变
              • org 级元数据覆盖(ADR-0005 overlay)按对象名/字段名键控 → 不变
              • 不拆模块的包仍是一个包,_packageId 不变
              • Studio 路由 /_console/studio/<packageId>/... 不变
              • 客户不需要做任何数据迁移

              6.2 逐条

              改动对存量客户
              §5.1 产物 schema前提:新增可选键、两种都读(有 packages 就遍历,没有就把 manifest 当单元素列表)。这样存量产物行为逐位不变
              §5.2 装载循环内部,无感
              §5.3 composeStacks新增可选项,默认仍是 'last' → 现有调用方无感
              §5.4 闸放宽放宽从不打破原本能跑的东西 —— 但必须与 §5.6 同批
              §5.5 Studio 选择器UI 增量
              §5.6 对象名唯一性新增的拒绝:只会拒掉今天本就会在 DB 层炸的配置,且拒得更早、更清楚

              6.3 前向兼容已有机制,不用新建

              产物里已有 manifest.engines.protocol(黑猫那份是 "^17.2.0")。新格式产物声明新的 protocol range,旧运行时会干净地拒绝而不是错误解析。

              6.4 建议的验收条件

              存量单 manifest 产物经新装载路径后,注册结果逐位相同(包记录、对象 FQN、_packageId 标记、命名空间所有者集合)。

              这条应当写成测试,而不是靠 review 判断。


              七、待决策

              ① 这条路走不走。 这是主问题。替代方案是 §2 的三条,各自的否决理由已实测列出。

              ② 拓扑序是必须项,不是优化项。 一个包用 defineObjectExtension 扩另一个包的对象,必须在被扩的包之后注册。今天跨物安装靠 manifest.dependenciesresolvePluginOrder 定序;N 个包塞进一个物之后,装载处必须拓扑排序,不能直接 for 数组。这是这套改动里唯一会静默出错的地方——顺序错了不报错,只是扩展没生效。请把它作为验收条件,不要留给实现者判断。

              ③ 一个物一个版本号,取舍认不认。 好处:版本矩阵直接消失,客户不可能装出 core 3.2 + cpq 1.4 这种没人测过的组合,defineObjectExtension 也不会因为被扩的包单独升级而悬空。代价:不能单独热修一个模块,任何一处修都要重发整物。对内部模块这个取舍是对的;对要单独售卖的模块不成立,那种应当独立成物。

              ④ 产物体积。 黑猫 30 个对象已经 2.6 MB。模块继续往一个 JSON 里加,市场传输和启动解析都会涨。不是拦路虎,但该在 schema 定下来的时候就决定要不要分段加载,而不是等它到 20 MB 再回头改格式。

              ⑤ 确认 owner 字段推迟。 本方案不加 manifest 的 owner/publisher 字段,理由见 §3.2。如果维护者认为应当与 ADR-0048 addendum D2 同批落地,请在此说明——那会让 §5 多一项。


              八、下游可以先做什么(不阻塞本 issue)

              黑猫和 hotcrm 的交付链路在我们手里,可以先按包拆源码结构、锁步发布(N 个包永远同版本、整批安装),等产物 schema 落地再切过去,源码结构不用返工。

              如实标注这条中间路的缺陷:锁步是约定,不是闸——没有任何机制阻止装成错配的一对。对自建交付可控,一旦上架市场就不成立。这正是本 issue 要解决的部分。

              Metadata

              Metadata

              Assignees

              No one assigned

                Type

                No type

                Projects

                No projects

                  Milestone

                  No milestone

                  Relationships

                  None yet

                  Development

                  No branches or pull requests

                  Issue actions

                  , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
                  Skip to content

                  One artifact, N packages: let a release bundle carry co-owning packages so a product can be split into modules without renaming objects #14122

                  Description

                  @hotlong

                  从下游应用提出(steedos-labs/hotcrm-heimaoobjectstack-ai/hotcrm)。全部结论实测于 @objectstack/*17.2.0 运行实例,非推断。

                  修订 1:补入 §5.6「安装时对象名唯一性检查」——放宽命名空间闸的安全前提,初版遗漏;并新增 §6 兼容性一节(对存量客户元数据应用的影响)。


                  一、业务需求

                  1.1 标准产品 + 客户定制,今天靠整仓复制

                  我们有标准 CRM(hotcrm),客户会在它上面做定制。今天的模式是把整个仓库复制一份随便改——黑猫(hotcrm-heimao)就是 hotcrm 的 fork。

                  fork 税是实测的,不是担心:把 @objectstack/* 从 17.1.0 升到 17.2.0,两个仓库各做一遍,同样的坑(sharing 播种、StackBlitz 双锁文件门)各踩一遍,知识靠人手搬。黑猫对标准 CRM 的改动其实约 95% 是增量——碰到标准对象的 6 个文件是 +1,000/−15 行,15 行删除全是替换而非删能力。也就是说:绝大部分本可以是"扩展",却因为没有边界而变成了"分叉"。

                  1.2 产品自身的模块也在扩张

                  不只是客户定制。hotcrm 想在销售模块上扩 CPQ;黑猫加了"订单与毛利测算"(12 个对象 + 8 个 hook)。今天这些都堆在同一个 src/,和标准 CRM 的对象平铺在一起。

                  1.3 由此产生的三个可度量的业务痛点

                  ① 维护者看不清自己的系统。 Studio 是元数据管理界面,作用域是软件包。黑猫这一个包里:

                  Studio 分区内容是否分组
                  数据30 个对象❌ 平铺
                  自动化29 条 flow❌ 平铺,且标签截断
                  界面导航树✅ 有分组
                  权限30 对象 × 9 列 CRUD × 6 个权限集❌ 平铺

                  自动化那一栏里,29 条 flow 有两条都截断成 Case Escalation…,肉眼分不出是哪两条。权限矩阵是一张 30 行 × 9 列的网格,把 客户/联系人/商机运费标准/等级政策/工厂成本 混在一起——给一个销售主管配权限,要在这张表里滚完 30 行。

                  「界面」分区之所以有分组,不是因为它做得更好,而是因为它渲染的恰好是唯一被作者写下来的那份分组app.navigation 的 group 节点)。对象、flow、权限集在 spec 里都没有任何分组键:ObjectSchemaBase 的顶层只有 name / label / pluralLabel / description / icon / isSystemFlowSchema 只有 name / label / description

                  ② 上下文预算没有模块边界。 产品定位是"一个 CRM 销售模块能完整装进 AI 的上下文窗口",并由 scripts/check-source-token-ratchet.mjs 机械守着。黑猫当前:

                  business semantics 112,746 / 115,000 headroom 2,254
                  interaction layer 44,495 / 48,000 headroom 3,505
                  authored total 178,417 / 180,000 headroom 1,583 ← 卡这里
                  

                  没有模块边界,就没有分模块的预算——所有模块一起顶到同一个天花板,谁也说不清是谁把预算吃掉了。

                  ③ 商业边界不存在。 CPQ 希望能单独售卖,但它和销售模块在同一个包里,没有可售的单元。


                  二、为什么三条显而易见的路都走不通(实测)

                  2.1 给对象/flow 加 module 标签 —— 预算不允许

                  ratchet 计入 190 个授权文件。每处 module: 'order_margin', 约 25 字符 ≈ 6 tokens,190 处 ≈ 1,100–1,200 tokens,占总量剩余额度的 70–75%。而抬 ceiling 是维护者的 floor item,不能由 agent 动。

                  一个纯分组键吃掉四分之三的剩余预算,性价比不成立。

                  2.2 拆成各自命名空间的独立包 —— 要给每个对象改名

                  SchemaRegistry.installPackage()(ADR-0048 §3.2):

                  if(manifest.namespace&&!isShareableNamespace(manifest.namespace)){constconflictOwner=getNamespaceOwners(ns).find(o=>o!==manifest.id);if(conflictOwner)thrownewNamespaceConflictError(...)}

                  判据是**"不是同一个包 id"**,豁免只有 RESERVED_NAMESPACES = {base, system}sys。加上 defineStackvalidateNamespacePrefix 要求 object.name === ${namespace}_${shortName}——N 个包就是 N 个命名空间,就是把 crm_account 改成 sales_account。表名、API 路径、公式、筛选器、集成、客户存的视图全断。

                  ADR-0048 §5 自己把这条列为已接受的非目标:"Namespace rename-on-install is an explicit non-goal for now (deep rewrite of every object name, cross-reference, and formula)."

                  2.3 用现成的 composeStacks 合并编译 —— 子包身份会丢

                  composeStacks 的关键选项:

                  manifest: z.union([z.enum(['first','last']),z.number().int().min(0)]).default('last')

                  它在 N 份 manifest 里挑一份,扔掉其余的。 现成的合并机制是"压平",不是"保留"。走完整个重构会落回一个 30 对象的平包——收益恰好在它该出现的地方蒸发。


                  三、最终方案:一个发布物,物内 N 个包

                  编译按包,产物仍是一个 JSON,JSON 里保留 N 份 manifest,装载时按依赖拓扑序注册 N 个包。

                  HotCRM 发布物(一个 JSON,一个版本号,应用市场照旧发一个文件)
                  ├── app.objectstack.hotcrm type: app ← 唯一的消费者单元,拥有应用与导航
                  ├── app.objectstack.hotcrm.cpq type: module ← 共用 crm 命名空间,navigationContributions 注入导航
                  └── app.objectstack.hotcrm.order type: module ← 同上
                  

                  3.1 为什么这样不违反 ADR-0019

                  ADR-0019 D2 已经分好层:app 是消费者单元,plugin/module/driver 是"内部贡献——.app bundle 里的框架,随 App 一起装,消费者从不单独浏览或安装"。D4 更是原文写着 "An App declares and owns a set of namespaces"——是 set,不是单个

                  所以消费者仍然装一个、开一个、卸一个,D1/D2/D3 原样成立。validateSingleApp 也不咬——它只约束 type: app

                  不能跨的线:这一层必须停在控制面。一旦让"发布物"成为消费者要浏览、要安装、要卸载的东西,那就是 ADR-0019 D3 点名禁掉的 suite,苹果那段论证原样适用。

                  3.2 共用命名空间的所有权证明就是"同一个物"

                  同一个物里来的包,是同一个发布者原子交付的。物本身就是共同所有权的声明,因此安装闸的判据从"同一个包 id"改成"同一个物内"即可,manifest不需要新增 owner 字段

                  (owner 字段只在 CPQ 哪天单独成物、单独售卖时才必须——那时它要向外声明"我和 HotCRM 同属一个发布者"。ADR-0048 的 2026-08-08 addendum D2 已经把发布侧的保留权定给了 publisher,届时两侧对齐即可。本 issue 把这件事推迟,不前置。)


                  四、实测支撑:注册层本来就是多包的

                  已经支持、不用改的:

                  事实位置
                  registerApp(manifest)按包函数:用该 manifest 的 id/namespace 给每个对象打 'own'objectql/src/engine.ts:4745
                  命名空间存储本来就是多所有者(namespaceRegistry 是 namespace → packageId 集合的 Map),getNamespaceOwners() 返回数组objectql/src/registry.ts:1456
                  卸载已经按包清,不是按命名空间扫registry.unregisterObjectsByPackage(packageId)
                  对象已有逐条 owner:contributors[{ packageId, ownership: 'own' | 'extend' }]同上
                  这个装机今天就注册着 23 个包,全走同一条路径/api/v1/meta/package

                  编写侧今天就通过: 子包声明 namespace: 'crm'、定义 crm_* 对象、用 navigationContributions 注入 crm_enterprise,三种 type 全部 defineStack ACCEPTED(plugin / module / app)。唯一的闸在 installPackage

                  跨包能力矩阵(逐条实测):

                  跨包做什么结果
                  lookup / 关系字段指向外包对象✅ ACCEPTED
                  navigationContributions 注入外包 app 且指向外包对象✅ ACCEPTED
                  本包 app 的 navigation 指向外包对象App 'x' navigation references object 'core_account' which is not defined in objects.
                  hook 挂到外包对象上Hook 'h' references object 'core_account' which is not defined in objects.

                  后两条的含义很具体:导航必须走 contributions(平台自己六个插件往 setup 注入就是这个形状);拆的缝必须顺着 hook 归属切。黑猫这条缝是干净的——它新增的 8 个 hook 全部挂在它自己新增的对象上。

                  产物现状:os buildos compile 的别名,产出一个 JSON(默认 dist/objectstack.json)。黑猫已构建的那份:manifest单个 dictapp.objectstack.hotcrm, ns crm, v3.0.0),objects 30 个平铺,2.6 MB,manifest.engines.protocol"^17.2.0"


                  五、要改的(应用市场链路不动)

                  不管物里装几个包,市场发的还是一个文件——分发、下载、上架、安装入口的传输形态全部不变。

                  #改动规模
                  1产物 schema:manifest 单数 → packages: [...] 列表。必须做成新增可选键、两种都读(见 §6)唯一实质的一处
                  2装载处循环:objectql/src/plugin.ts:405ql.registerApp(manifest) → 遍历一个循环
                  3composeStacks 增加保留模式(今天 'first'/'last'/index 是刻意的挑选语义),默认值不变
                  4installPackage 命名空间闸认"同一个物内的共同所有者"一行判据
                  5Studio 包选择器列出 project 域的 module 包(今天只显示 app.objectstack.hotcrm,22 个系统插件被过滤)objectui 侧,另开
                  6安装时逐对象名唯一性检查 —— 放宽 §5.4 那道闸的安全前提,见下小,但不可省

                  5.6 为什么对象名唯一性检查不是可选项

                  命名空间独占今天在代理另一件事。ADR-0048 原文:

                  objects dodge collisions because their names are namespace-prefixed (crm_account) and map to physical tables; a clash fails loudly at the DB

                  也就是说,"两个包不能共用命名空间"这道闸,顺带保证了"不会有两个包定义同名对象"。一旦按 §5.4 放宽它而不补显式检查,同一个物里两个包都定义 crm_account 时:

                  • 安装期不报错
                  • 拖到 DB 层才炸(重复建表),或者按驱动实现不同,可能一个包静默覆盖另一个包的表定义 —— 这是唯一会真正伤到客户数据的形态

                  所以 §5.4 与 §5.6 必须同批落地,不能拆开发布。


                  六、兼容性:对存量客户元数据应用的影响

                  结论:不拆模块的存量客户零影响——前提是 §5.1 做成增量而非替换。

                  6.1 核心原因:本方案不改名

                  这既是它相对 §2.2 的根本优势,也正是兼容性的答案:

                  • 对象名不变 → 表名、REST 路径、公式、筛选器、客户存的视图全部不变
                  • org 级元数据覆盖(ADR-0005 overlay)按对象名/字段名键控 → 不变
                  • 不拆模块的包仍是一个包,_packageId 不变
                  • Studio 路由 /_console/studio/<packageId>/... 不变
                  • 客户不需要做任何数据迁移

                  6.2 逐条

                  改动对存量客户
                  §5.1 产物 schema前提:新增可选键、两种都读(有 packages 就遍历,没有就把 manifest 当单元素列表)。这样存量产物行为逐位不变
                  §5.2 装载循环内部,无感
                  §5.3 composeStacks新增可选项,默认仍是 'last' → 现有调用方无感
                  §5.4 闸放宽放宽从不打破原本能跑的东西 —— 但必须与 §5.6 同批
                  §5.5 Studio 选择器UI 增量
                  §5.6 对象名唯一性新增的拒绝:只会拒掉今天本就会在 DB 层炸的配置,且拒得更早、更清楚

                  6.3 前向兼容已有机制,不用新建

                  产物里已有 manifest.engines.protocol(黑猫那份是 "^17.2.0")。新格式产物声明新的 protocol range,旧运行时会干净地拒绝而不是错误解析。

                  6.4 建议的验收条件

                  存量单 manifest 产物经新装载路径后,注册结果逐位相同(包记录、对象 FQN、_packageId 标记、命名空间所有者集合)。

                  这条应当写成测试,而不是靠 review 判断。


                  七、待决策

                  ① 这条路走不走。 这是主问题。替代方案是 §2 的三条,各自的否决理由已实测列出。

                  ② 拓扑序是必须项,不是优化项。 一个包用 defineObjectExtension 扩另一个包的对象,必须在被扩的包之后注册。今天跨物安装靠 manifest.dependenciesresolvePluginOrder 定序;N 个包塞进一个物之后,装载处必须拓扑排序,不能直接 for 数组。这是这套改动里唯一会静默出错的地方——顺序错了不报错,只是扩展没生效。请把它作为验收条件,不要留给实现者判断。

                  ③ 一个物一个版本号,取舍认不认。 好处:版本矩阵直接消失,客户不可能装出 core 3.2 + cpq 1.4 这种没人测过的组合,defineObjectExtension 也不会因为被扩的包单独升级而悬空。代价:不能单独热修一个模块,任何一处修都要重发整物。对内部模块这个取舍是对的;对要单独售卖的模块不成立,那种应当独立成物。

                  ④ 产物体积。 黑猫 30 个对象已经 2.6 MB。模块继续往一个 JSON 里加,市场传输和启动解析都会涨。不是拦路虎,但该在 schema 定下来的时候就决定要不要分段加载,而不是等它到 20 MB 再回头改格式。

                  ⑤ 确认 owner 字段推迟。 本方案不加 manifest 的 owner/publisher 字段,理由见 §3.2。如果维护者认为应当与 ADR-0048 addendum D2 同批落地,请在此说明——那会让 §5 多一项。


                  八、下游可以先做什么(不阻塞本 issue)

                  黑猫和 hotcrm 的交付链路在我们手里,可以先按包拆源码结构、锁步发布(N 个包永远同版本、整批安装),等产物 schema 落地再切过去,源码结构不用返工。

                  如实标注这条中间路的缺陷:锁步是约定,不是闸——没有任何机制阻止装成错配的一对。对自建交付可控,一旦上架市场就不成立。这正是本 issue 要解决的部分。

                  Metadata

                  Metadata

                  Assignees

                  No one assigned

                    Type

                    No type

                    Projects

                    No projects

                      Milestone

                      No milestone

                      Relationships

                      None yet

                      Development

                      No branches or pull requests

                      Issue actions

                      , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
                      Skip to content

                      One artifact, N packages: let a release bundle carry co-owning packages so a product can be split into modules without renaming objects #14122

                      Description

                      @hotlong

                      从下游应用提出(steedos-labs/hotcrm-heimaoobjectstack-ai/hotcrm)。全部结论实测于 @objectstack/*17.2.0 运行实例,非推断。

                      修订 1:补入 §5.6「安装时对象名唯一性检查」——放宽命名空间闸的安全前提,初版遗漏;并新增 §6 兼容性一节(对存量客户元数据应用的影响)。


                      一、业务需求

                      1.1 标准产品 + 客户定制,今天靠整仓复制

                      我们有标准 CRM(hotcrm),客户会在它上面做定制。今天的模式是把整个仓库复制一份随便改——黑猫(hotcrm-heimao)就是 hotcrm 的 fork。

                      fork 税是实测的,不是担心:把 @objectstack/* 从 17.1.0 升到 17.2.0,两个仓库各做一遍,同样的坑(sharing 播种、StackBlitz 双锁文件门)各踩一遍,知识靠人手搬。黑猫对标准 CRM 的改动其实约 95% 是增量——碰到标准对象的 6 个文件是 +1,000/−15 行,15 行删除全是替换而非删能力。也就是说:绝大部分本可以是"扩展",却因为没有边界而变成了"分叉"。

                      1.2 产品自身的模块也在扩张

                      不只是客户定制。hotcrm 想在销售模块上扩 CPQ;黑猫加了"订单与毛利测算"(12 个对象 + 8 个 hook)。今天这些都堆在同一个 src/,和标准 CRM 的对象平铺在一起。

                      1.3 由此产生的三个可度量的业务痛点

                      ① 维护者看不清自己的系统。 Studio 是元数据管理界面,作用域是软件包。黑猫这一个包里:

                      Studio 分区内容是否分组
                      数据30 个对象❌ 平铺
                      自动化29 条 flow❌ 平铺,且标签截断
                      界面导航树✅ 有分组
                      权限30 对象 × 9 列 CRUD × 6 个权限集❌ 平铺

                      自动化那一栏里,29 条 flow 有两条都截断成 Case Escalation…,肉眼分不出是哪两条。权限矩阵是一张 30 行 × 9 列的网格,把 客户/联系人/商机运费标准/等级政策/工厂成本 混在一起——给一个销售主管配权限,要在这张表里滚完 30 行。

                      「界面」分区之所以有分组,不是因为它做得更好,而是因为它渲染的恰好是唯一被作者写下来的那份分组app.navigation 的 group 节点)。对象、flow、权限集在 spec 里都没有任何分组键:ObjectSchemaBase 的顶层只有 name / label / pluralLabel / description / icon / isSystemFlowSchema 只有 name / label / description

                      ② 上下文预算没有模块边界。 产品定位是"一个 CRM 销售模块能完整装进 AI 的上下文窗口",并由 scripts/check-source-token-ratchet.mjs 机械守着。黑猫当前:

                      business semantics 112,746 / 115,000 headroom 2,254
                      interaction layer 44,495 / 48,000 headroom 3,505
                      authored total 178,417 / 180,000 headroom 1,583 ← 卡这里
                      

                      没有模块边界,就没有分模块的预算——所有模块一起顶到同一个天花板,谁也说不清是谁把预算吃掉了。

                      ③ 商业边界不存在。 CPQ 希望能单独售卖,但它和销售模块在同一个包里,没有可售的单元。


                      二、为什么三条显而易见的路都走不通(实测)

                      2.1 给对象/flow 加 module 标签 —— 预算不允许

                      ratchet 计入 190 个授权文件。每处 module: 'order_margin', 约 25 字符 ≈ 6 tokens,190 处 ≈ 1,100–1,200 tokens,占总量剩余额度的 70–75%。而抬 ceiling 是维护者的 floor item,不能由 agent 动。

                      一个纯分组键吃掉四分之三的剩余预算,性价比不成立。

                      2.2 拆成各自命名空间的独立包 —— 要给每个对象改名

                      SchemaRegistry.installPackage()(ADR-0048 §3.2):

                      if(manifest.namespace&&!isShareableNamespace(manifest.namespace)){constconflictOwner=getNamespaceOwners(ns).find(o=>o!==manifest.id);if(conflictOwner)thrownewNamespaceConflictError(...)}

                      判据是**"不是同一个包 id"**,豁免只有 RESERVED_NAMESPACES = {base, system}sys。加上 defineStackvalidateNamespacePrefix 要求 object.name === ${namespace}_${shortName}——N 个包就是 N 个命名空间,就是把 crm_account 改成 sales_account。表名、API 路径、公式、筛选器、集成、客户存的视图全断。

                      ADR-0048 §5 自己把这条列为已接受的非目标:"Namespace rename-on-install is an explicit non-goal for now (deep rewrite of every object name, cross-reference, and formula)."

                      2.3 用现成的 composeStacks 合并编译 —— 子包身份会丢

                      composeStacks 的关键选项:

                      manifest: z.union([z.enum(['first','last']),z.number().int().min(0)]).default('last')

                      它在 N 份 manifest 里挑一份,扔掉其余的。 现成的合并机制是"压平",不是"保留"。走完整个重构会落回一个 30 对象的平包——收益恰好在它该出现的地方蒸发。


                      三、最终方案:一个发布物,物内 N 个包

                      编译按包,产物仍是一个 JSON,JSON 里保留 N 份 manifest,装载时按依赖拓扑序注册 N 个包。

                      HotCRM 发布物(一个 JSON,一个版本号,应用市场照旧发一个文件)
                      ├── app.objectstack.hotcrm type: app ← 唯一的消费者单元,拥有应用与导航
                      ├── app.objectstack.hotcrm.cpq type: module ← 共用 crm 命名空间,navigationContributions 注入导航
                      └── app.objectstack.hotcrm.order type: module ← 同上
                      

                      3.1 为什么这样不违反 ADR-0019

                      ADR-0019 D2 已经分好层:app 是消费者单元,plugin/module/driver 是"内部贡献——.app bundle 里的框架,随 App 一起装,消费者从不单独浏览或安装"。D4 更是原文写着 "An App declares and owns a set of namespaces"——是 set,不是单个

                      所以消费者仍然装一个、开一个、卸一个,D1/D2/D3 原样成立。validateSingleApp 也不咬——它只约束 type: app

                      不能跨的线:这一层必须停在控制面。一旦让"发布物"成为消费者要浏览、要安装、要卸载的东西,那就是 ADR-0019 D3 点名禁掉的 suite,苹果那段论证原样适用。

                      3.2 共用命名空间的所有权证明就是"同一个物"

                      同一个物里来的包,是同一个发布者原子交付的。物本身就是共同所有权的声明,因此安装闸的判据从"同一个包 id"改成"同一个物内"即可,manifest不需要新增 owner 字段

                      (owner 字段只在 CPQ 哪天单独成物、单独售卖时才必须——那时它要向外声明"我和 HotCRM 同属一个发布者"。ADR-0048 的 2026-08-08 addendum D2 已经把发布侧的保留权定给了 publisher,届时两侧对齐即可。本 issue 把这件事推迟,不前置。)


                      四、实测支撑:注册层本来就是多包的

                      已经支持、不用改的:

                      事实位置
                      registerApp(manifest)按包函数:用该 manifest 的 id/namespace 给每个对象打 'own'objectql/src/engine.ts:4745
                      命名空间存储本来就是多所有者(namespaceRegistry 是 namespace → packageId 集合的 Map),getNamespaceOwners() 返回数组objectql/src/registry.ts:1456
                      卸载已经按包清,不是按命名空间扫registry.unregisterObjectsByPackage(packageId)
                      对象已有逐条 owner:contributors[{ packageId, ownership: 'own' | 'extend' }]同上
                      这个装机今天就注册着 23 个包,全走同一条路径/api/v1/meta/package

                      编写侧今天就通过: 子包声明 namespace: 'crm'、定义 crm_* 对象、用 navigationContributions 注入 crm_enterprise,三种 type 全部 defineStack ACCEPTED(plugin / module / app)。唯一的闸在 installPackage

                      跨包能力矩阵(逐条实测):

                      跨包做什么结果
                      lookup / 关系字段指向外包对象✅ ACCEPTED
                      navigationContributions 注入外包 app 且指向外包对象✅ ACCEPTED
                      本包 app 的 navigation 指向外包对象App 'x' navigation references object 'core_account' which is not defined in objects.
                      hook 挂到外包对象上Hook 'h' references object 'core_account' which is not defined in objects.

                      后两条的含义很具体:导航必须走 contributions(平台自己六个插件往 setup 注入就是这个形状);拆的缝必须顺着 hook 归属切。黑猫这条缝是干净的——它新增的 8 个 hook 全部挂在它自己新增的对象上。

                      产物现状:os buildos compile 的别名,产出一个 JSON(默认 dist/objectstack.json)。黑猫已构建的那份:manifest单个 dictapp.objectstack.hotcrm, ns crm, v3.0.0),objects 30 个平铺,2.6 MB,manifest.engines.protocol"^17.2.0"


                      五、要改的(应用市场链路不动)

                      不管物里装几个包,市场发的还是一个文件——分发、下载、上架、安装入口的传输形态全部不变。

                      #改动规模
                      1产物 schema:manifest 单数 → packages: [...] 列表。必须做成新增可选键、两种都读(见 §6)唯一实质的一处
                      2装载处循环:objectql/src/plugin.ts:405ql.registerApp(manifest) → 遍历一个循环
                      3composeStacks 增加保留模式(今天 'first'/'last'/index 是刻意的挑选语义),默认值不变
                      4installPackage 命名空间闸认"同一个物内的共同所有者"一行判据
                      5Studio 包选择器列出 project 域的 module 包(今天只显示 app.objectstack.hotcrm,22 个系统插件被过滤)objectui 侧,另开
                      6安装时逐对象名唯一性检查 —— 放宽 §5.4 那道闸的安全前提,见下小,但不可省

                      5.6 为什么对象名唯一性检查不是可选项

                      命名空间独占今天在代理另一件事。ADR-0048 原文:

                      objects dodge collisions because their names are namespace-prefixed (crm_account) and map to physical tables; a clash fails loudly at the DB

                      也就是说,"两个包不能共用命名空间"这道闸,顺带保证了"不会有两个包定义同名对象"。一旦按 §5.4 放宽它而不补显式检查,同一个物里两个包都定义 crm_account 时:

                      • 安装期不报错
                      • 拖到 DB 层才炸(重复建表),或者按驱动实现不同,可能一个包静默覆盖另一个包的表定义 —— 这是唯一会真正伤到客户数据的形态

                      所以 §5.4 与 §5.6 必须同批落地,不能拆开发布。


                      六、兼容性:对存量客户元数据应用的影响

                      结论:不拆模块的存量客户零影响——前提是 §5.1 做成增量而非替换。

                      6.1 核心原因:本方案不改名

                      这既是它相对 §2.2 的根本优势,也正是兼容性的答案:

                      • 对象名不变 → 表名、REST 路径、公式、筛选器、客户存的视图全部不变
                      • org 级元数据覆盖(ADR-0005 overlay)按对象名/字段名键控 → 不变
                      • 不拆模块的包仍是一个包,_packageId 不变
                      • Studio 路由 /_console/studio/<packageId>/... 不变
                      • 客户不需要做任何数据迁移

                      6.2 逐条

                      改动对存量客户
                      §5.1 产物 schema前提:新增可选键、两种都读(有 packages 就遍历,没有就把 manifest 当单元素列表)。这样存量产物行为逐位不变
                      §5.2 装载循环内部,无感
                      §5.3 composeStacks新增可选项,默认仍是 'last' → 现有调用方无感
                      §5.4 闸放宽放宽从不打破原本能跑的东西 —— 但必须与 §5.6 同批
                      §5.5 Studio 选择器UI 增量
                      §5.6 对象名唯一性新增的拒绝:只会拒掉今天本就会在 DB 层炸的配置,且拒得更早、更清楚

                      6.3 前向兼容已有机制,不用新建

                      产物里已有 manifest.engines.protocol(黑猫那份是 "^17.2.0")。新格式产物声明新的 protocol range,旧运行时会干净地拒绝而不是错误解析。

                      6.4 建议的验收条件

                      存量单 manifest 产物经新装载路径后,注册结果逐位相同(包记录、对象 FQN、_packageId 标记、命名空间所有者集合)。

                      这条应当写成测试,而不是靠 review 判断。


                      七、待决策

                      ① 这条路走不走。 这是主问题。替代方案是 §2 的三条,各自的否决理由已实测列出。

                      ② 拓扑序是必须项,不是优化项。 一个包用 defineObjectExtension 扩另一个包的对象,必须在被扩的包之后注册。今天跨物安装靠 manifest.dependenciesresolvePluginOrder 定序;N 个包塞进一个物之后,装载处必须拓扑排序,不能直接 for 数组。这是这套改动里唯一会静默出错的地方——顺序错了不报错,只是扩展没生效。请把它作为验收条件,不要留给实现者判断。

                      ③ 一个物一个版本号,取舍认不认。 好处:版本矩阵直接消失,客户不可能装出 core 3.2 + cpq 1.4 这种没人测过的组合,defineObjectExtension 也不会因为被扩的包单独升级而悬空。代价:不能单独热修一个模块,任何一处修都要重发整物。对内部模块这个取舍是对的;对要单独售卖的模块不成立,那种应当独立成物。

                      ④ 产物体积。 黑猫 30 个对象已经 2.6 MB。模块继续往一个 JSON 里加,市场传输和启动解析都会涨。不是拦路虎,但该在 schema 定下来的时候就决定要不要分段加载,而不是等它到 20 MB 再回头改格式。

                      ⑤ 确认 owner 字段推迟。 本方案不加 manifest 的 owner/publisher 字段,理由见 §3.2。如果维护者认为应当与 ADR-0048 addendum D2 同批落地,请在此说明——那会让 §5 多一项。


                      八、下游可以先做什么(不阻塞本 issue)

                      黑猫和 hotcrm 的交付链路在我们手里,可以先按包拆源码结构、锁步发布(N 个包永远同版本、整批安装),等产物 schema 落地再切过去,源码结构不用返工。

                      如实标注这条中间路的缺陷:锁步是约定,不是闸——没有任何机制阻止装成错配的一对。对自建交付可控,一旦上架市场就不成立。这正是本 issue 要解决的部分。

                      Metadata

                      Metadata

                      Assignees

                      No one assigned

                        Type

                        No type

                        Projects

                        No projects

                          Milestone

                          No milestone

                          Relationships

                          None yet

                          Development

                          No branches or pull requests

                          Issue actions

                          , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
                          Skip to content

                          One artifact, N packages: let a release bundle carry co-owning packages so a product can be split into modules without renaming objects #14122

                          Description

                          @hotlong

                          从下游应用提出(steedos-labs/hotcrm-heimaoobjectstack-ai/hotcrm)。全部结论实测于 @objectstack/*17.2.0 运行实例,非推断。

                          修订 1:补入 §5.6「安装时对象名唯一性检查」——放宽命名空间闸的安全前提,初版遗漏;并新增 §6 兼容性一节(对存量客户元数据应用的影响)。


                          一、业务需求

                          1.1 标准产品 + 客户定制,今天靠整仓复制

                          我们有标准 CRM(hotcrm),客户会在它上面做定制。今天的模式是把整个仓库复制一份随便改——黑猫(hotcrm-heimao)就是 hotcrm 的 fork。

                          fork 税是实测的,不是担心:把 @objectstack/* 从 17.1.0 升到 17.2.0,两个仓库各做一遍,同样的坑(sharing 播种、StackBlitz 双锁文件门)各踩一遍,知识靠人手搬。黑猫对标准 CRM 的改动其实约 95% 是增量——碰到标准对象的 6 个文件是 +1,000/−15 行,15 行删除全是替换而非删能力。也就是说:绝大部分本可以是"扩展",却因为没有边界而变成了"分叉"。

                          1.2 产品自身的模块也在扩张

                          不只是客户定制。hotcrm 想在销售模块上扩 CPQ;黑猫加了"订单与毛利测算"(12 个对象 + 8 个 hook)。今天这些都堆在同一个 src/,和标准 CRM 的对象平铺在一起。

                          1.3 由此产生的三个可度量的业务痛点

                          ① 维护者看不清自己的系统。 Studio 是元数据管理界面,作用域是软件包。黑猫这一个包里:

                          Studio 分区内容是否分组
                          数据30 个对象❌ 平铺
                          自动化29 条 flow❌ 平铺,且标签截断
                          界面导航树✅ 有分组
                          权限30 对象 × 9 列 CRUD × 6 个权限集❌ 平铺

                          自动化那一栏里,29 条 flow 有两条都截断成 Case Escalation…,肉眼分不出是哪两条。权限矩阵是一张 30 行 × 9 列的网格,把 客户/联系人/商机运费标准/等级政策/工厂成本 混在一起——给一个销售主管配权限,要在这张表里滚完 30 行。

                          「界面」分区之所以有分组,不是因为它做得更好,而是因为它渲染的恰好是唯一被作者写下来的那份分组app.navigation 的 group 节点)。对象、flow、权限集在 spec 里都没有任何分组键:ObjectSchemaBase 的顶层只有 name / label / pluralLabel / description / icon / isSystemFlowSchema 只有 name / label / description

                          ② 上下文预算没有模块边界。 产品定位是"一个 CRM 销售模块能完整装进 AI 的上下文窗口",并由 scripts/check-source-token-ratchet.mjs 机械守着。黑猫当前:

                          business semantics 112,746 / 115,000 headroom 2,254
                          interaction layer 44,495 / 48,000 headroom 3,505
                          authored total 178,417 / 180,000 headroom 1,583 ← 卡这里
                          

                          没有模块边界,就没有分模块的预算——所有模块一起顶到同一个天花板,谁也说不清是谁把预算吃掉了。

                          ③ 商业边界不存在。 CPQ 希望能单独售卖,但它和销售模块在同一个包里,没有可售的单元。


                          二、为什么三条显而易见的路都走不通(实测)

                          2.1 给对象/flow 加 module 标签 —— 预算不允许

                          ratchet 计入 190 个授权文件。每处 module: 'order_margin', 约 25 字符 ≈ 6 tokens,190 处 ≈ 1,100–1,200 tokens,占总量剩余额度的 70–75%。而抬 ceiling 是维护者的 floor item,不能由 agent 动。

                          一个纯分组键吃掉四分之三的剩余预算,性价比不成立。

                          2.2 拆成各自命名空间的独立包 —— 要给每个对象改名

                          SchemaRegistry.installPackage()(ADR-0048 §3.2):

                          if(manifest.namespace&&!isShareableNamespace(manifest.namespace)){constconflictOwner=getNamespaceOwners(ns).find(o=>o!==manifest.id);if(conflictOwner)thrownewNamespaceConflictError(...)}

                          判据是**"不是同一个包 id"**,豁免只有 RESERVED_NAMESPACES = {base, system}sys。加上 defineStackvalidateNamespacePrefix 要求 object.name === ${namespace}_${shortName}——N 个包就是 N 个命名空间,就是把 crm_account 改成 sales_account。表名、API 路径、公式、筛选器、集成、客户存的视图全断。

                          ADR-0048 §5 自己把这条列为已接受的非目标:"Namespace rename-on-install is an explicit non-goal for now (deep rewrite of every object name, cross-reference, and formula)."

                          2.3 用现成的 composeStacks 合并编译 —— 子包身份会丢

                          composeStacks 的关键选项:

                          manifest: z.union([z.enum(['first','last']),z.number().int().min(0)]).default('last')

                          它在 N 份 manifest 里挑一份,扔掉其余的。 现成的合并机制是"压平",不是"保留"。走完整个重构会落回一个 30 对象的平包——收益恰好在它该出现的地方蒸发。


                          三、最终方案:一个发布物,物内 N 个包

                          编译按包,产物仍是一个 JSON,JSON 里保留 N 份 manifest,装载时按依赖拓扑序注册 N 个包。

                          HotCRM 发布物(一个 JSON,一个版本号,应用市场照旧发一个文件)
                          ├── app.objectstack.hotcrm type: app ← 唯一的消费者单元,拥有应用与导航
                          ├── app.objectstack.hotcrm.cpq type: module ← 共用 crm 命名空间,navigationContributions 注入导航
                          └── app.objectstack.hotcrm.order type: module ← 同上
                          

                          3.1 为什么这样不违反 ADR-0019

                          ADR-0019 D2 已经分好层:app 是消费者单元,plugin/module/driver 是"内部贡献——.app bundle 里的框架,随 App 一起装,消费者从不单独浏览或安装"。D4 更是原文写着 "An App declares and owns a set of namespaces"——是 set,不是单个

                          所以消费者仍然装一个、开一个、卸一个,D1/D2/D3 原样成立。validateSingleApp 也不咬——它只约束 type: app

                          不能跨的线:这一层必须停在控制面。一旦让"发布物"成为消费者要浏览、要安装、要卸载的东西,那就是 ADR-0019 D3 点名禁掉的 suite,苹果那段论证原样适用。

                          3.2 共用命名空间的所有权证明就是"同一个物"

                          同一个物里来的包,是同一个发布者原子交付的。物本身就是共同所有权的声明,因此安装闸的判据从"同一个包 id"改成"同一个物内"即可,manifest不需要新增 owner 字段

                          (owner 字段只在 CPQ 哪天单独成物、单独售卖时才必须——那时它要向外声明"我和 HotCRM 同属一个发布者"。ADR-0048 的 2026-08-08 addendum D2 已经把发布侧的保留权定给了 publisher,届时两侧对齐即可。本 issue 把这件事推迟,不前置。)


                          四、实测支撑:注册层本来就是多包的

                          已经支持、不用改的:

                          事实位置
                          registerApp(manifest)按包函数:用该 manifest 的 id/namespace 给每个对象打 'own'objectql/src/engine.ts:4745
                          命名空间存储本来就是多所有者(namespaceRegistry 是 namespace → packageId 集合的 Map),getNamespaceOwners() 返回数组objectql/src/registry.ts:1456
                          卸载已经按包清,不是按命名空间扫registry.unregisterObjectsByPackage(packageId)
                          对象已有逐条 owner:contributors[{ packageId, ownership: 'own' | 'extend' }]同上
                          这个装机今天就注册着 23 个包,全走同一条路径/api/v1/meta/package

                          编写侧今天就通过: 子包声明 namespace: 'crm'、定义 crm_* 对象、用 navigationContributions 注入 crm_enterprise,三种 type 全部 defineStack ACCEPTED(plugin / module / app)。唯一的闸在 installPackage

                          跨包能力矩阵(逐条实测):

                          跨包做什么结果
                          lookup / 关系字段指向外包对象✅ ACCEPTED
                          navigationContributions 注入外包 app 且指向外包对象✅ ACCEPTED
                          本包 app 的 navigation 指向外包对象App 'x' navigation references object 'core_account' which is not defined in objects.
                          hook 挂到外包对象上Hook 'h' references object 'core_account' which is not defined in objects.

                          后两条的含义很具体:导航必须走 contributions(平台自己六个插件往 setup 注入就是这个形状);拆的缝必须顺着 hook 归属切。黑猫这条缝是干净的——它新增的 8 个 hook 全部挂在它自己新增的对象上。

                          产物现状:os buildos compile 的别名,产出一个 JSON(默认 dist/objectstack.json)。黑猫已构建的那份:manifest单个 dictapp.objectstack.hotcrm, ns crm, v3.0.0),objects 30 个平铺,2.6 MB,manifest.engines.protocol"^17.2.0"


                          五、要改的(应用市场链路不动)

                          不管物里装几个包,市场发的还是一个文件——分发、下载、上架、安装入口的传输形态全部不变。

                          #改动规模
                          1产物 schema:manifest 单数 → packages: [...] 列表。必须做成新增可选键、两种都读(见 §6)唯一实质的一处
                          2装载处循环:objectql/src/plugin.ts:405ql.registerApp(manifest) → 遍历一个循环
                          3composeStacks 增加保留模式(今天 'first'/'last'/index 是刻意的挑选语义),默认值不变
                          4installPackage 命名空间闸认"同一个物内的共同所有者"一行判据
                          5Studio 包选择器列出 project 域的 module 包(今天只显示 app.objectstack.hotcrm,22 个系统插件被过滤)objectui 侧,另开
                          6安装时逐对象名唯一性检查 —— 放宽 §5.4 那道闸的安全前提,见下小,但不可省

                          5.6 为什么对象名唯一性检查不是可选项

                          命名空间独占今天在代理另一件事。ADR-0048 原文:

                          objects dodge collisions because their names are namespace-prefixed (crm_account) and map to physical tables; a clash fails loudly at the DB

                          也就是说,"两个包不能共用命名空间"这道闸,顺带保证了"不会有两个包定义同名对象"。一旦按 §5.4 放宽它而不补显式检查,同一个物里两个包都定义 crm_account 时:

                          • 安装期不报错
                          • 拖到 DB 层才炸(重复建表),或者按驱动实现不同,可能一个包静默覆盖另一个包的表定义 —— 这是唯一会真正伤到客户数据的形态

                          所以 §5.4 与 §5.6 必须同批落地,不能拆开发布。


                          六、兼容性:对存量客户元数据应用的影响

                          结论:不拆模块的存量客户零影响——前提是 §5.1 做成增量而非替换。

                          6.1 核心原因:本方案不改名

                          这既是它相对 §2.2 的根本优势,也正是兼容性的答案:

                          • 对象名不变 → 表名、REST 路径、公式、筛选器、客户存的视图全部不变
                          • org 级元数据覆盖(ADR-0005 overlay)按对象名/字段名键控 → 不变
                          • 不拆模块的包仍是一个包,_packageId 不变
                          • Studio 路由 /_console/studio/<packageId>/... 不变
                          • 客户不需要做任何数据迁移

                          6.2 逐条

                          改动对存量客户
                          §5.1 产物 schema前提:新增可选键、两种都读(有 packages 就遍历,没有就把 manifest 当单元素列表)。这样存量产物行为逐位不变
                          §5.2 装载循环内部,无感
                          §5.3 composeStacks新增可选项,默认仍是 'last' → 现有调用方无感
                          §5.4 闸放宽放宽从不打破原本能跑的东西 —— 但必须与 §5.6 同批
                          §5.5 Studio 选择器UI 增量
                          §5.6 对象名唯一性新增的拒绝:只会拒掉今天本就会在 DB 层炸的配置,且拒得更早、更清楚

                          6.3 前向兼容已有机制,不用新建

                          产物里已有 manifest.engines.protocol(黑猫那份是 "^17.2.0")。新格式产物声明新的 protocol range,旧运行时会干净地拒绝而不是错误解析。

                          6.4 建议的验收条件

                          存量单 manifest 产物经新装载路径后,注册结果逐位相同(包记录、对象 FQN、_packageId 标记、命名空间所有者集合)。

                          这条应当写成测试,而不是靠 review 判断。


                          七、待决策

                          ① 这条路走不走。 这是主问题。替代方案是 §2 的三条,各自的否决理由已实测列出。

                          ② 拓扑序是必须项,不是优化项。 一个包用 defineObjectExtension 扩另一个包的对象,必须在被扩的包之后注册。今天跨物安装靠 manifest.dependenciesresolvePluginOrder 定序;N 个包塞进一个物之后,装载处必须拓扑排序,不能直接 for 数组。这是这套改动里唯一会静默出错的地方——顺序错了不报错,只是扩展没生效。请把它作为验收条件,不要留给实现者判断。

                          ③ 一个物一个版本号,取舍认不认。 好处:版本矩阵直接消失,客户不可能装出 core 3.2 + cpq 1.4 这种没人测过的组合,defineObjectExtension 也不会因为被扩的包单独升级而悬空。代价:不能单独热修一个模块,任何一处修都要重发整物。对内部模块这个取舍是对的;对要单独售卖的模块不成立,那种应当独立成物。

                          ④ 产物体积。 黑猫 30 个对象已经 2.6 MB。模块继续往一个 JSON 里加,市场传输和启动解析都会涨。不是拦路虎,但该在 schema 定下来的时候就决定要不要分段加载,而不是等它到 20 MB 再回头改格式。

                          ⑤ 确认 owner 字段推迟。 本方案不加 manifest 的 owner/publisher 字段,理由见 §3.2。如果维护者认为应当与 ADR-0048 addendum D2 同批落地,请在此说明——那会让 §5 多一项。


                          八、下游可以先做什么(不阻塞本 issue)

                          黑猫和 hotcrm 的交付链路在我们手里,可以先按包拆源码结构、锁步发布(N 个包永远同版本、整批安装),等产物 schema 落地再切过去,源码结构不用返工。

                          如实标注这条中间路的缺陷:锁步是约定,不是闸——没有任何机制阻止装成错配的一对。对自建交付可控,一旦上架市场就不成立。这正是本 issue 要解决的部分。

                          Metadata

                          Metadata

                          Assignees

                          No one assigned

                            Type

                            No type

                            Projects

                            No projects

                              Milestone

                              No milestone

                              Relationships

                              None yet

                              Development

                              No branches or pull requests

                              Issue actions

                              , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
                              Skip to content

                              One artifact, N packages: let a release bundle carry co-owning packages so a product can be split into modules without renaming objects #14122

                              Description

                              @hotlong

                              从下游应用提出(steedos-labs/hotcrm-heimaoobjectstack-ai/hotcrm)。全部结论实测于 @objectstack/*17.2.0 运行实例,非推断。

                              修订 1:补入 §5.6「安装时对象名唯一性检查」——放宽命名空间闸的安全前提,初版遗漏;并新增 §6 兼容性一节(对存量客户元数据应用的影响)。


                              一、业务需求

                              1.1 标准产品 + 客户定制,今天靠整仓复制

                              我们有标准 CRM(hotcrm),客户会在它上面做定制。今天的模式是把整个仓库复制一份随便改——黑猫(hotcrm-heimao)就是 hotcrm 的 fork。

                              fork 税是实测的,不是担心:把 @objectstack/* 从 17.1.0 升到 17.2.0,两个仓库各做一遍,同样的坑(sharing 播种、StackBlitz 双锁文件门)各踩一遍,知识靠人手搬。黑猫对标准 CRM 的改动其实约 95% 是增量——碰到标准对象的 6 个文件是 +1,000/−15 行,15 行删除全是替换而非删能力。也就是说:绝大部分本可以是"扩展",却因为没有边界而变成了"分叉"。

                              1.2 产品自身的模块也在扩张

                              不只是客户定制。hotcrm 想在销售模块上扩 CPQ;黑猫加了"订单与毛利测算"(12 个对象 + 8 个 hook)。今天这些都堆在同一个 src/,和标准 CRM 的对象平铺在一起。

                              1.3 由此产生的三个可度量的业务痛点

                              ① 维护者看不清自己的系统。 Studio 是元数据管理界面,作用域是软件包。黑猫这一个包里:

                              Studio 分区内容是否分组
                              数据30 个对象❌ 平铺
                              自动化29 条 flow❌ 平铺,且标签截断
                              界面导航树✅ 有分组
                              权限30 对象 × 9 列 CRUD × 6 个权限集❌ 平铺

                              自动化那一栏里,29 条 flow 有两条都截断成 Case Escalation…,肉眼分不出是哪两条。权限矩阵是一张 30 行 × 9 列的网格,把 客户/联系人/商机运费标准/等级政策/工厂成本 混在一起——给一个销售主管配权限,要在这张表里滚完 30 行。

                              「界面」分区之所以有分组,不是因为它做得更好,而是因为它渲染的恰好是唯一被作者写下来的那份分组app.navigation 的 group 节点)。对象、flow、权限集在 spec 里都没有任何分组键:ObjectSchemaBase 的顶层只有 name / label / pluralLabel / description / icon / isSystemFlowSchema 只有 name / label / description

                              ② 上下文预算没有模块边界。 产品定位是"一个 CRM 销售模块能完整装进 AI 的上下文窗口",并由 scripts/check-source-token-ratchet.mjs 机械守着。黑猫当前:

                              business semantics 112,746 / 115,000 headroom 2,254
                              interaction layer 44,495 / 48,000 headroom 3,505
                              authored total 178,417 / 180,000 headroom 1,583 ← 卡这里
                              

                              没有模块边界,就没有分模块的预算——所有模块一起顶到同一个天花板,谁也说不清是谁把预算吃掉了。

                              ③ 商业边界不存在。 CPQ 希望能单独售卖,但它和销售模块在同一个包里,没有可售的单元。


                              二、为什么三条显而易见的路都走不通(实测)

                              2.1 给对象/flow 加 module 标签 —— 预算不允许

                              ratchet 计入 190 个授权文件。每处 module: 'order_margin', 约 25 字符 ≈ 6 tokens,190 处 ≈ 1,100–1,200 tokens,占总量剩余额度的 70–75%。而抬 ceiling 是维护者的 floor item,不能由 agent 动。

                              一个纯分组键吃掉四分之三的剩余预算,性价比不成立。

                              2.2 拆成各自命名空间的独立包 —— 要给每个对象改名

                              SchemaRegistry.installPackage()(ADR-0048 §3.2):

                              if(manifest.namespace&&!isShareableNamespace(manifest.namespace)){constconflictOwner=getNamespaceOwners(ns).find(o=>o!==manifest.id);if(conflictOwner)thrownewNamespaceConflictError(...)}

                              判据是**"不是同一个包 id"**,豁免只有 RESERVED_NAMESPACES = {base, system}sys。加上 defineStackvalidateNamespacePrefix 要求 object.name === ${namespace}_${shortName}——N 个包就是 N 个命名空间,就是把 crm_account 改成 sales_account。表名、API 路径、公式、筛选器、集成、客户存的视图全断。

                              ADR-0048 §5 自己把这条列为已接受的非目标:"Namespace rename-on-install is an explicit non-goal for now (deep rewrite of every object name, cross-reference, and formula)."

                              2.3 用现成的 composeStacks 合并编译 —— 子包身份会丢

                              composeStacks 的关键选项:

                              manifest: z.union([z.enum(['first','last']),z.number().int().min(0)]).default('last')

                              它在 N 份 manifest 里挑一份,扔掉其余的。 现成的合并机制是"压平",不是"保留"。走完整个重构会落回一个 30 对象的平包——收益恰好在它该出现的地方蒸发。


                              三、最终方案:一个发布物,物内 N 个包

                              编译按包,产物仍是一个 JSON,JSON 里保留 N 份 manifest,装载时按依赖拓扑序注册 N 个包。

                              HotCRM 发布物(一个 JSON,一个版本号,应用市场照旧发一个文件)
                              ├── app.objectstack.hotcrm type: app ← 唯一的消费者单元,拥有应用与导航
                              ├── app.objectstack.hotcrm.cpq type: module ← 共用 crm 命名空间,navigationContributions 注入导航
                              └── app.objectstack.hotcrm.order type: module ← 同上
                              

                              3.1 为什么这样不违反 ADR-0019

                              ADR-0019 D2 已经分好层:app 是消费者单元,plugin/module/driver 是"内部贡献——.app bundle 里的框架,随 App 一起装,消费者从不单独浏览或安装"。D4 更是原文写着 "An App declares and owns a set of namespaces"——是 set,不是单个

                              所以消费者仍然装一个、开一个、卸一个,D1/D2/D3 原样成立。validateSingleApp 也不咬——它只约束 type: app

                              不能跨的线:这一层必须停在控制面。一旦让"发布物"成为消费者要浏览、要安装、要卸载的东西,那就是 ADR-0019 D3 点名禁掉的 suite,苹果那段论证原样适用。

                              3.2 共用命名空间的所有权证明就是"同一个物"

                              同一个物里来的包,是同一个发布者原子交付的。物本身就是共同所有权的声明,因此安装闸的判据从"同一个包 id"改成"同一个物内"即可,manifest不需要新增 owner 字段

                              (owner 字段只在 CPQ 哪天单独成物、单独售卖时才必须——那时它要向外声明"我和 HotCRM 同属一个发布者"。ADR-0048 的 2026-08-08 addendum D2 已经把发布侧的保留权定给了 publisher,届时两侧对齐即可。本 issue 把这件事推迟,不前置。)


                              四、实测支撑:注册层本来就是多包的

                              已经支持、不用改的:

                              事实位置
                              registerApp(manifest)按包函数:用该 manifest 的 id/namespace 给每个对象打 'own'objectql/src/engine.ts:4745
                              命名空间存储本来就是多所有者(namespaceRegistry 是 namespace → packageId 集合的 Map),getNamespaceOwners() 返回数组objectql/src/registry.ts:1456
                              卸载已经按包清,不是按命名空间扫registry.unregisterObjectsByPackage(packageId)
                              对象已有逐条 owner:contributors[{ packageId, ownership: 'own' | 'extend' }]同上
                              这个装机今天就注册着 23 个包,全走同一条路径/api/v1/meta/package

                              编写侧今天就通过: 子包声明 namespace: 'crm'、定义 crm_* 对象、用 navigationContributions 注入 crm_enterprise,三种 type 全部 defineStack ACCEPTED(plugin / module / app)。唯一的闸在 installPackage

                              跨包能力矩阵(逐条实测):

                              跨包做什么结果
                              lookup / 关系字段指向外包对象✅ ACCEPTED
                              navigationContributions 注入外包 app 且指向外包对象✅ ACCEPTED
                              本包 app 的 navigation 指向外包对象App 'x' navigation references object 'core_account' which is not defined in objects.
                              hook 挂到外包对象上Hook 'h' references object 'core_account' which is not defined in objects.

                              后两条的含义很具体:导航必须走 contributions(平台自己六个插件往 setup 注入就是这个形状);拆的缝必须顺着 hook 归属切。黑猫这条缝是干净的——它新增的 8 个 hook 全部挂在它自己新增的对象上。

                              产物现状:os buildos compile 的别名,产出一个 JSON(默认 dist/objectstack.json)。黑猫已构建的那份:manifest单个 dictapp.objectstack.hotcrm, ns crm, v3.0.0),objects 30 个平铺,2.6 MB,manifest.engines.protocol"^17.2.0"


                              五、要改的(应用市场链路不动)

                              不管物里装几个包,市场发的还是一个文件——分发、下载、上架、安装入口的传输形态全部不变。

                              #改动规模
                              1产物 schema:manifest 单数 → packages: [...] 列表。必须做成新增可选键、两种都读(见 §6)唯一实质的一处
                              2装载处循环:objectql/src/plugin.ts:405ql.registerApp(manifest) → 遍历一个循环
                              3composeStacks 增加保留模式(今天 'first'/'last'/index 是刻意的挑选语义),默认值不变
                              4installPackage 命名空间闸认"同一个物内的共同所有者"一行判据
                              5Studio 包选择器列出 project 域的 module 包(今天只显示 app.objectstack.hotcrm,22 个系统插件被过滤)objectui 侧,另开
                              6安装时逐对象名唯一性检查 —— 放宽 §5.4 那道闸的安全前提,见下小,但不可省

                              5.6 为什么对象名唯一性检查不是可选项

                              命名空间独占今天在代理另一件事。ADR-0048 原文:

                              objects dodge collisions because their names are namespace-prefixed (crm_account) and map to physical tables; a clash fails loudly at the DB

                              也就是说,"两个包不能共用命名空间"这道闸,顺带保证了"不会有两个包定义同名对象"。一旦按 §5.4 放宽它而不补显式检查,同一个物里两个包都定义 crm_account 时:

                              • 安装期不报错
                              • 拖到 DB 层才炸(重复建表),或者按驱动实现不同,可能一个包静默覆盖另一个包的表定义 —— 这是唯一会真正伤到客户数据的形态

                              所以 §5.4 与 §5.6 必须同批落地,不能拆开发布。


                              六、兼容性:对存量客户元数据应用的影响

                              结论:不拆模块的存量客户零影响——前提是 §5.1 做成增量而非替换。

                              6.1 核心原因:本方案不改名

                              这既是它相对 §2.2 的根本优势,也正是兼容性的答案:

                              • 对象名不变 → 表名、REST 路径、公式、筛选器、客户存的视图全部不变
                              • org 级元数据覆盖(ADR-0005 overlay)按对象名/字段名键控 → 不变
                              • 不拆模块的包仍是一个包,_packageId 不变
                              • Studio 路由 /_console/studio/<packageId>/... 不变
                              • 客户不需要做任何数据迁移

                              6.2 逐条

                              改动对存量客户
                              §5.1 产物 schema前提:新增可选键、两种都读(有 packages 就遍历,没有就把 manifest 当单元素列表)。这样存量产物行为逐位不变
                              §5.2 装载循环内部,无感
                              §5.3 composeStacks新增可选项,默认仍是 'last' → 现有调用方无感
                              §5.4 闸放宽放宽从不打破原本能跑的东西 —— 但必须与 §5.6 同批
                              §5.5 Studio 选择器UI 增量
                              §5.6 对象名唯一性新增的拒绝:只会拒掉今天本就会在 DB 层炸的配置,且拒得更早、更清楚

                              6.3 前向兼容已有机制,不用新建

                              产物里已有 manifest.engines.protocol(黑猫那份是 "^17.2.0")。新格式产物声明新的 protocol range,旧运行时会干净地拒绝而不是错误解析。

                              6.4 建议的验收条件

                              存量单 manifest 产物经新装载路径后,注册结果逐位相同(包记录、对象 FQN、_packageId 标记、命名空间所有者集合)。

                              这条应当写成测试,而不是靠 review 判断。


                              七、待决策

                              ① 这条路走不走。 这是主问题。替代方案是 §2 的三条,各自的否决理由已实测列出。

                              ② 拓扑序是必须项,不是优化项。 一个包用 defineObjectExtension 扩另一个包的对象,必须在被扩的包之后注册。今天跨物安装靠 manifest.dependenciesresolvePluginOrder 定序;N 个包塞进一个物之后,装载处必须拓扑排序,不能直接 for 数组。这是这套改动里唯一会静默出错的地方——顺序错了不报错,只是扩展没生效。请把它作为验收条件,不要留给实现者判断。

                              ③ 一个物一个版本号,取舍认不认。 好处:版本矩阵直接消失,客户不可能装出 core 3.2 + cpq 1.4 这种没人测过的组合,defineObjectExtension 也不会因为被扩的包单独升级而悬空。代价:不能单独热修一个模块,任何一处修都要重发整物。对内部模块这个取舍是对的;对要单独售卖的模块不成立,那种应当独立成物。

                              ④ 产物体积。 黑猫 30 个对象已经 2.6 MB。模块继续往一个 JSON 里加,市场传输和启动解析都会涨。不是拦路虎,但该在 schema 定下来的时候就决定要不要分段加载,而不是等它到 20 MB 再回头改格式。

                              ⑤ 确认 owner 字段推迟。 本方案不加 manifest 的 owner/publisher 字段,理由见 §3.2。如果维护者认为应当与 ADR-0048 addendum D2 同批落地,请在此说明——那会让 §5 多一项。


                              八、下游可以先做什么(不阻塞本 issue)

                              黑猫和 hotcrm 的交付链路在我们手里,可以先按包拆源码结构、锁步发布(N 个包永远同版本、整批安装),等产物 schema 落地再切过去,源码结构不用返工。

                              如实标注这条中间路的缺陷:锁步是约定,不是闸——没有任何机制阻止装成错配的一对。对自建交付可控,一旦上架市场就不成立。这正是本 issue 要解决的部分。

                              Metadata

                              Metadata

                              Assignees

                              No one assigned

                                Type

                                No type

                                Projects

                                No projects

                                  Milestone

                                  No milestone

                                  Relationships

                                  None yet

                                  Development

                                  No branches or pull requests

                                  Issue actions