从下游应用提出(steedos-labs/hotcrm-heimao、objectstack-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 / isSystem;FlowSchema 只有 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。加上 defineStack 的 validateNamespacePrefix 要求 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 build 是 os compile 的别名,产出一个 JSON(默认 dist/objectstack.json)。黑猫已构建的那份:manifest 是单个 dict(app.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:405 的 ql.registerApp(manifest) → 遍历 | 一个循环 |
| 3 | composeStacks 增加保留模式(今天 'first'/'last'/index 是刻意的挑选语义),默认值不变 | 小 |
| 4 | installPackage 命名空间闸认"同一个物内的共同所有者" | 一行判据 |
| 5 | Studio 包选择器列出 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.dependencies → resolvePluginOrder 定序;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 要解决的部分。
从下游应用提出(
steedos-labs/hotcrm-heimao、objectstack-ai/hotcrm)。全部结论实测于@objectstack/*17.2.0 运行实例,非推断。一、业务需求
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 是元数据管理界面,作用域是软件包。黑猫这一个包里:
自动化那一栏里,29 条 flow 有两条都截断成
Case Escalation…,肉眼分不出是哪两条。权限矩阵是一张 30 行 × 9 列的网格,把客户/联系人/商机和运费标准/等级政策/工厂成本混在一起——给一个销售主管配权限,要在这张表里滚完 30 行。「界面」分区之所以有分组,不是因为它做得更好,而是因为它渲染的恰好是唯一被作者写下来的那份分组(
app.navigation的 group 节点)。对象、flow、权限集在 spec 里都没有任何分组键:ObjectSchemaBase的顶层只有name / label / pluralLabel / description / icon / isSystem;FlowSchema只有name / label / description。② 上下文预算没有模块边界。 产品定位是"一个 CRM 销售模块能完整装进 AI 的上下文窗口",并由
scripts/check-source-token-ratchet.mjs机械守着。黑猫当前:没有模块边界,就没有分模块的预算——所有模块一起顶到同一个天花板,谁也说不清是谁把预算吃掉了。
③ 商业边界不存在。 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):判据是**"不是同一个包 id"**,豁免只有
RESERVED_NAMESPACES = {base, system}加sys。加上defineStack的validateNamespacePrefix要求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的关键选项:它在 N 份 manifest 里挑一份,扔掉其余的。 现成的合并机制是"压平",不是"保留"。走完整个重构会落回一个 30 对象的平包——收益恰好在它该出现的地方蒸发。
三、最终方案:一个发布物,物内 N 个包
编译按包,产物仍是一个 JSON,JSON 里保留 N 份 manifest,装载时按依赖拓扑序注册 N 个包。
3.1 为什么这样不违反 ADR-0019
ADR-0019 D2 已经分好层:
app是消费者单元,plugin/module/driver是"内部贡献——.appbundle 里的框架,随 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:4745namespaceRegistry是 namespace → packageId 集合的 Map),getNamespaceOwners()返回数组objectql/src/registry.ts:1456registry.unregisterObjectsByPackage(packageId)contributors[{ packageId, ownership: 'own' | 'extend' }]/api/v1/meta/package编写侧今天就通过: 子包声明
namespace: 'crm'、定义crm_*对象、用navigationContributions注入crm_enterprise,三种type全部defineStackACCEPTED(plugin/module/app)。唯一的闸在installPackage。跨包能力矩阵(逐条实测):
navigationContributions注入外包 app 且指向外包对象navigation指向外包对象App 'x' navigation references object 'core_account' which is not defined in objects.Hook 'h' references object 'core_account' which is not defined in objects.后两条的含义很具体:导航必须走 contributions(平台自己六个插件往
setup注入就是这个形状);拆的缝必须顺着 hook 归属切。黑猫这条缝是干净的——它新增的 8 个 hook 全部挂在它自己新增的对象上。产物现状:
os build是os compile的别名,产出一个 JSON(默认dist/objectstack.json)。黑猫已构建的那份:manifest是单个 dict(app.objectstack.hotcrm, nscrm, v3.0.0),objects30 个平铺,2.6 MB,manifest.engines.protocol为"^17.2.0"。五、要改的(应用市场链路不动)
不管物里装几个包,市场发的还是一个文件——分发、下载、上架、安装入口的传输形态全部不变。
manifest单数 →packages: [...]列表。必须做成新增可选键、两种都读(见 §6)objectql/src/plugin.ts:405的ql.registerApp(manifest)→ 遍历composeStacks增加保留模式(今天'first'/'last'/index 是刻意的挑选语义),默认值不变installPackage命名空间闸认"同一个物内的共同所有者"app.objectstack.hotcrm,22 个系统插件被过滤)5.6 为什么对象名唯一性检查不是可选项
命名空间独占今天在代理另一件事。ADR-0048 原文:
也就是说,"两个包不能共用命名空间"这道闸,顺带保证了"不会有两个包定义同名对象"。一旦按 §5.4 放宽它而不补显式检查,同一个物里两个包都定义
crm_account时:所以 §5.4 与 §5.6 必须同批落地,不能拆开发布。
六、兼容性:对存量客户元数据应用的影响
结论:不拆模块的存量客户零影响——前提是 §5.1 做成增量而非替换。
6.1 核心原因:本方案不改名
这既是它相对 §2.2 的根本优势,也正是兼容性的答案:
_packageId不变/_console/studio/<packageId>/...不变6.2 逐条
packages就遍历,没有就把manifest当单元素列表)。这样存量产物行为逐位不变composeStacks'last'→ 现有调用方无感6.3 前向兼容已有机制,不用新建
产物里已有
manifest.engines.protocol(黑猫那份是"^17.2.0")。新格式产物声明新的 protocol range,旧运行时会干净地拒绝而不是错误解析。6.4 建议的验收条件
这条应当写成测试,而不是靠 review 判断。
七、待决策
① 这条路走不走。 这是主问题。替代方案是 §2 的三条,各自的否决理由已实测列出。
② 拓扑序是必须项,不是优化项。 一个包用
defineObjectExtension扩另一个包的对象,必须在被扩的包之后注册。今天跨物安装靠manifest.dependencies→resolvePluginOrder定序;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 要解决的部分。