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

Closed
16 of 16 issues completed

Description

@hotlong

从下游应用提出(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)) {
  const conflictOwner = getNamespaceOwners(ns).find(o => o !== manifest.id);
  if (conflictOwner) throw new NamespaceConflictError(...)
}

判据是**"不是同一个包 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 要解决的部分。

Activity

  1. hotlong commented on Sep 2, 2026

    @hotlong
    ContributorAuthor

    进度:#14375 落地(PR #14430 07:59 合并);objectui#7331 在队列;hotcrm 分拆方案待维护者合

    发版前清单(维护者指令:平台侧齐了再发)

    # 项 状态
    1 装载链 #14191 / #14223 / #14240 / #14354 ✅ 在 main
    2 #14375 服务端 writable ✅ 在 main(07:59)
    3 #14439 多包编译路径(含 #14242 → B) 🟡 agent 在飞
    4 objectui#7177 + .objectui-sha 提针 🟡 PR #7331 在队列;提针方式待维护者答复
    5 #14442 / #14451 ⚪ 是否挡发版待维护者定
    ✅ #14161 · #14144 · #14164 · #14162 · #14163 · #14375   6/8
    🟡 #14439   编译路径 · agent 在飞
    🟡 objectui#7177   PR #7331 队列中
    🆕 #14454   跨包规则矩阵补全(needs-user-decision:权限集)
    ⚪ #14442 · #14451   独立缺陷卡
    

    Generated by Claude Code

  2. hotlong commented on Sep 2, 2026

    @hotlong
    ContributorAuthor

    进度:objectui#7177 落地(PR objectui#7331 08:00 squash 合并)→ 7/8

    Studio 切换器现在读服务端 writable,缺失回落旧启发式;多包 Studio 复跑挂 #14439 落地后。发版前清单第 4 项剩 .objectui-sha 提针(方式待维护者答复)。

    ✅ #14161 · #14144 · #14164 · #14162 · #14163 · #14375 · objectui#7177   7/8
    🟡 #14439   编译路径 · agent 在飞(发版最后一块平台功能)
    🆕 #14454   跨包规则矩阵补全(needs-user-decision:权限集)
    ⚪ #14442 · #14451   独立缺陷卡(是否挡发版:维护者定)
    ⏸  hotcrm#1449   分拆方案 ready,等维护者拍板并合
    

    Generated by Claude Code

  3. hotlong commented on Sep 2, 2026

    @hotlong
    ContributorAuthor

    维护者六项决定(2026-09-02)与执行

    决定 结论 执行
    hotcrm R4 两条 campaign hook A:随对象搬进 sales 记入分拆方案文档(hotcrm#1449 新增 "Decisions recorded" 节),随 PR 合并
    hotcrm AGENTS.md packages/<x>/src/ 规则 C→B:等 #14439 定编译路径;能平铺就平铺 N 个 manifest,ratchet 不动 同上记入方案
    #14454 第 3 项 权限集 本次 B,A 为第二阶段 ADR-0130 补记 → #14487(docs-only,受管,draft 交维护者合,已派发);按包组合授权 → #14488(不挡发版)
    #14442 包读门循环引用 500 挡发版,小卡并行修 已认领、priority:p1、已派发(方向 A:注册载荷剥掉运行时实例)
    #14451 对代码包 duplicate 得空 base 不挡,triage 已定方向 实现符合 ADR-0070 D4 字面(duplicate 只克隆 base);错的是 422 文案与两处文档 → 对非 base 源明确 422 + 改文案(见 #14451 评论),#14439 后排期
    console .objectui-sha 提针 A:随发版流程 无需单独立卡;objectui main 已含 #7331

    #14454 第 1、2、4 项(9 类命名对象的跨包矩阵、navigationContributions[].group 语义、dashboard/report → dataset 跨包绑定)为测量与 pin,已认领派发,不挡发版。

    发版前清单(更新)

    # 项 状态
    1 装载链 #14191 / #14223 / #14240 / #14354 ✅
    2 #14375 服务端 writable(PR #14430) ✅
    3 objectui#7177(PR #7331) ✅
    4 #14439 多包编译路径 🟡 agent 在飞,8 提交,收尾中
    5 #14442 包读门 500 🟡 已派发
    6 .objectui-sha 提针 随发版流程
    ✅ #14161 · #14144 · #14164 · #14162 · #14163 · #14375 · objectui#7177   7/8
    🟡 #14439   编译路径 · agent 在飞(发版)
    🟡 #14442   包读门 500 · agent 在飞(发版)
    🟡 #14487   ADR-0130 补记 · agent 在飞(受管,维护者合)
    🟡 #14454   跨包矩阵测量 · agent 在飞
    ⏸  hotcrm#1449   决定已记入文档,CI 后合并
    ⚪ #14451 · #14488   不挡发版,排期在后
    

    Generated by Claude Code

  4. hotlong commented on Sep 2, 2026

    @hotlong
    ContributorAuthor

    进度:hotcrm 分拆方案落地(hotcrm#1449 09:12 squash 合并,含三项决定的记录)

    hotcrm 侧的设计交付完成:docs/architecture/module-split-plan.md + module-split-inventory.json 进 main,"Decisions recorded (2026-09-02)" 一节记下 R4 取 A、AGENTS.md 路径规则等 #14439、权限集本次 B / 二期 A(#14488)。代码分拆等 #14439 + 发版 + hotcrm bump。

    ✅ #14161 · #14144 · #14164 · #14162 · #14163 · #14375 · objectui#7177 · hotcrm#1448
    🟡 #14439   编译路径 · agent 在飞(发版)
    🟡 #14442   包读门 500 · agent 在飞(发版)
    🟡 #14487   ADR-0130 补记 · agent 在飞(受管,维护者合)
    🟡 #14454   跨包矩阵测量 · agent 在飞
    ⚪ #14451 · #14488   不挡发版,排期在后
    

    Generated by Claude Code

  5. hotlong commented on Sep 2, 2026

    @hotlong
    ContributorAuthor

    §4 跨包能力矩阵 —— 补全(#14454 第 1、2、4 项,可直接折进正文 §4)

    来源:#14454,PR #14549(draft,tests-only,22 条 pin)。方法与 §4 相同:两包同物共命名空间,经真实装载路径 manifest.register({ packages: [...] }) 装进 booted ObjectKernel(ADR-0130 D1 / #14354 的 ArtifactInstallScope);A 拥有 crm_account,B 从被测项类点名它。

    ⚠️ 一处对 §4 自身的结构性更正:四条规则不出自同一道门

    §4 记了四条判定但没记是哪道门给的,而它们不是同一道:

    • 编写门 —— defineStack → validateCrossReferences(packages/spec/src/stack.zod.ts:1109)。它只看得见一个 stack,所以「co-owner 的对象」与「拼错」在它眼里无从区分。§4 那两条 REFUSE 的报错原文正是这里的,逐字。
    • 装载门 —— registerApp → installPackage(packages/objectql/src/engine.ts:4797)。它对下列任何项类的对象引用都不校验,一律经 registerMetadataCollections 注册。

    ⇒ 只读装载门会得到九类清一色 ACCEPTED(真,但无用);只读编写门会漏掉「运行时什么都不管」。故下表判定取实效判定:编写门拒 = 模块根本写不出来,装载门怎么想都不影响这个结论。

    矩阵(file:line 在 packages/objectql/src/registry-cross-package-item-classes.test.ts)

    跨包做什么 结果 报错 / code pin
    lookup / 关系字段指向外包对象 ✅ ACCEPTED(§4 原有) — —
    navigationContributions 注入外包 app 且指向外包对象 ✅ ACCEPTED(§4 原有) — —
    本包 app 的 navigation 指向外包对象 ❌ REFUSED(§4 原有,编写门) App 'x' navigation references object 'core_account' which is not defined in objects. —
    hook 挂到外包对象上 ❌ REFUSED(§4 原有,编写门) Hook 'h' references object 'core_account' which is not defined in objects. :236 连续性对照,本轮以同一方法复现
    action 的 objectName ❌ REFUSED(编写门)· 装载门 ACCEPTED Action 'NAME' references object 'OBJECT' which is not defined in objects. :282 / :316
    view 的 data.object ❌ REFUSED(编写门)· 装载门 ACCEPTED View[0].list references object 'OBJECT' which is not defined in objects. :335
    page 的记录对象 object ⚠️ ACCEPTED(无人校验) 两道门都不查 :384 / :434
    page 的 related-list 对象 regions[].components[].dataSource.object ⚠️ ACCEPTED(无人校验) 两道门都不查 :448
    dataset 的 object ⚠️ ACCEPTED(无人校验) 两道门都不查 :392 / :434
    sharing rule 的 object ⚠️ ACCEPTED(无人校验) 两道门都不查 :410 / :434
    permission set 的 objects ❌ REFUSED(编写门)· 装载门 ACCEPTED Permission 'NAME' grants on object 'OBJECT' which is not defined in objects. :290 / :316
    seed dataset 的 object ❌ REFUSED(编写门)· 装载门 ACCEPTED Seed data references object 'OBJECT' which is not defined in objects. :298 / :316
    import mapping 的 targetObject ❌ REFUSED(编写门)· 装载门 ACCEPTED Mapping 'NAME' targets object 'OBJECT' which is not defined in objects. :306 / :316
    flow 节点的 object(START 节点 config.objectName) ⚠️ ACCEPTED(无人校验;config 是开放 z.record) 两道门都不查 :477
    dashboard widget 的 dataset(第 4 项) ✅ ACCEPTED 两道门都不查 :529
    report 的 dataset(第 4 项) ✅ ACCEPTED 两道门都不查 :551

    新测九类:5 拒 / 7 受(含第 4 项两条)。

    ⚠️ 「ACCEPTED(无人校验)」不等于 ACCEPTED:这些类跨包能注册也能解析(模块分拆需要的正是这个),但它们对指向根本不存在的对象同样照单全收。每条 pin 都断言了这后半句(crm_nowhere / ds_nowhere),所以这是一条发现,不是绿灯。

    第 4 项结论

    一个模块可以自带自己的 dashboard。 dashboard widget 与 report 绑定别的包定义的 dataset,两道门都接受;pin 同时读回 B 的 widget(_packageId = B、dataset 名完好)与 A 的 dataset(_packageId = A、object = A 的对象)。

    第 2 项:navigationContributions[].group(packages/objectql/src/registry-nav-contribution-group-semantics.test.ts)

    命题 结论 pin
    1. group 按目标 app 声明的 group 节点解析 ✅ 成立 —— DFS 同时匹配 id 与 type === 'group',追加进该 group 的 children,不在顶层重复 :143、:158
    2. 贡献进不存在的 group 要可见地失败而非静默消失 🔴 两者皆非,见下 :170、:186、:245
    3. 多包贡献同一 group 由 priority 定序而非注册顺序 ✅ 成立 —— priority 升序;同值稳定回落到到达序;schema 默认 200 实测落在显式 100 与 300 之间 :270、:284

    🔴 命题 2 需要维护者/PM 拍板 —— 平台做的是第三件事:把贡献「上提」到 app 顶层。

    不消失,也不失败:无 throw、无 code/status、无 console.warn、无 console.error(:186 断言这两个通道都为空)。唯一痕迹是 registry 私有 log() 的一行 console.log,按 logLevel info/debug 开关。出厂默认是 info,所以默认启动会打一行;但在 warn——正是 OS_REGISTRY_LOG 用来选的档,也是本包自己 vitest.config.ts 选的档(#13517)——relocation 照做,一个字不出。编写门也拦不住(:245):navigationContributions 不在任何 cross-reference 规则里,拼错的 group id 两道门全过。

    这比方案担心的「静默丢弃」更糟。 丢了会有人发现;被上提的条目在场、看着正常,而信息架构已经变了 —— 那恰是 17 个导航节点转换要保住的唯一属性。:158 另证同一条路径也会被一个拼写正确但指向 object 类型节点(而非 group)的 id 触发。

    ⛔ 按卡片围栏,本卡不改运行时行为,pin 只记录今天的行为。选项与四轴推荐已写在 #14454 的 os-dev-report(推荐 B:把那行 console.log 升为 console.warn,并让 hotcrm 转换自带一条 IA 断言;A「装载门拒绝」会引入 registerAppNavContribution 今天刻意没有的装载顺序约束)。

    🟡 顺带一条:编写门没有 ADR-0112 信封

    五条 REFUSED 全是 defineStack 抛的裸 new Error(...),code 与 status 都是 undefined——所以「拒了,什么 code」的答案是:没有 code。报错 message 是区分不同拒收的唯一判据,故 pin 断言 message,并在 :251 单独把信封的缺席也钉住(转红 = 信封来了,届时改 pin 与本表,⛔ 不是删断言)。与 #14367(registerObject 裸 Error)同形,隔一道门。


    Generated by Claude Code

  6. hotlong commented on Sep 2, 2026

    @hotlong
    ContributorAuthor

    §4 fold-in — cross-package matrix measured (#14454 → PR #14549, merged)

    Two pin files now on main extend the §4 table; every row reads both doors — the authoring gate (defineStack → validateCrossReferences, which sees one stack and cannot tell a co-owner's object from a typo) and the install gate (registerApp → installPackage, which validates no object reference on any of these classes). Method as §4: packages A and B installed as same-artifact co-owners of one namespace through manifest.register({ packages: [...] }) on a booted kernel; A owns crm_account, B names it.

    Item 1 — object-naming classes (packages/objectql/src/registry-cross-package-item-classes.test.ts):

    Item class Key Effective verdict
    action objectName REFUSED at authoring · accepted at install
    view data.object REFUSED at authoring · accepted at install
    permission set objects REFUSED at authoring · accepted at install
    seed dataset object REFUSED at authoring · accepted at install
    import mapping targetObject REFUSED at authoring · accepted at install
    page (record) object ACCEPTED, unenforced at both doors
    page (related list) regions[].components[].dataSource.object ACCEPTED, unenforced
    dataset object ACCEPTED, unenforced
    sharing rule object ACCEPTED, unenforced
    flow node (START) config.objectName ACCEPTED, unenforced (open z.record)

    5 REFUSED / 5 ACCEPTED. REFUSED rows carry the local-object control (same fixture, object declared locally → accepted); ACCEPTED rows carry the dangling-name control and read the item back (_packageId = B, resolveObject = A's definition, getNamespaceOwners('crm') = both). §4's R4 (hooks[].object) is re-measured with this method and reproduces its message verbatim.

    Item 4 — analytics binding: a dashboard widget and a report in B binding a dataset defined in A are ACCEPTED at both doors and resolve. A module can carry its own dashboard.

    Item 2 — navigationContributions[].group (packages/objectql/src/registry-nav-contribution-group-semantics.test.ts): proposition 1 holds (resolves against a type: 'group' node the target app declares; an id that names a non-group node is not a match); proposition 3 holds (ordered by priority, stable tie-break to arrival, schema default 200). Proposition 2 does not hold as posed: a contribution into a group the app does not declare is neither dropped nor refused — it is relocated to the app's top level, with no throw, no code/status, no console.warn; the only trace is one console.log gated at info, silent at OS_REGISTRY_LOG=warn. The authoring gate cannot see it either.

    Item 3 stays as ruled: option B, permission sets whole in the type: app package (#14487 / PR #14498, addendum for the maintainer's hand-merge; #14488 for phase 2).

    Routed: #14553 (needs-user-decision) for the silent relocation — options A refuse / B warn / C consumer-side test, recommendation B now + C in hotcrm; #14552 (bug) for the five REFUSED refusals carrying no ADR-0112 envelope, same class as #14367 / #14474. Neither gates the release.


    Generated by Claude Code

  7. hotlong commented on Sep 2, 2026

    @hotlong
    ContributorAuthor

    Landing + post-landing verification (PM seat)

    Release checklist, platform side: #14599 (this feature's own defect — one owner per object across doors, Studio grouping) and #14499 (#14309's fix, another seat, conflicted with main and idle since 09:34). Everything else in the original eight cards is on main; #14498 is the ADR addendum awaiting the maintainer's hand-merge and does not gate.


    Generated by Claude Code

  8. hotlong commented on Sep 2, 2026

    @hotlong
    ContributorAuthor

    #14599 landed — post-landing verification on main PASSES (PM seat)

    ADR-0130 consequence row 6 (Studio grouping by package) now holds on main.

    Platform release checklist: every card of this epic that gates the feature is on main (#14375, #14439, #14454, #14599; #14442 closed as a duplicate of #14309). The one open gating item is #14499 (#14309's fix, another seat's PR — its queue runs were green at 17:45; landing is that seat's). Not gating: #14498 (ADR addendum, maintainer hand-merge), #14512 producer half (needs-user-decision, rec B), #14553 (needs-user-decision, rec B+C), #14569 (needs-user-decision, rec A measured), #14597 (fixture note), #14552 (envelope bug).


    Generated by Claude Code

  9. hotlong commented on Sep 2, 2026

    @hotlong
    ContributorAuthor

    Platform release checklist for this epic — clear (PM seat, 20:24 UTC)

    PR #14499 (#14309, the package-record serialization fix that #14442 duplicated) merged at 20:21 UTC. With #14513 (#14439) and #14643 (#14599) already on main and verified end-to-end on examples/app-multi-package, nothing on the platform side of this epic gates the release any more.

    Still open, none gating: #14498 (ADR-0130 addendum, governed — maintainer hand-merge), #14512 (producer half, needs-user-decision, recommendation B), #14553 (needs-user-decision, recommendation B + C), #14569 (needs-user-decision, recommendation A with a measured ceiling), #14552 and #14597 (findings). The hotcrm module split (hotcrm#1449 plan) starts against the released version that carries packages[].


    Generated by Claude Code

  10. hotlong commented on Sep 4, 2026

    @hotlong
    ContributorAuthor

    进度 — 平台半又落两张,读取程序已启动(2026-09-04 05:14Z)

    PM 席,session session_01UHvF5hyiZjnceExFnfQB8m 的接续会话 session_01UHvF5hyiZjnCyExFnfQB8m。维护者 04:38 把两张 PR 同时入队,05:08 / 05:14 相继合入。

    刚落地

    卡 PR 内容
    #14553 #14920 导航贡献落在不存在的 group 时不再静默:读时折叠不变、不加拒绝,但记 ADR-0038 BuildIssue 家族的诊断、按 registry 每个 mis-aim 一次、经 level-aware warn() 活过 OS_REGISTRY_LOG=warn;os build 与 os validate 在编译期回答同一问题,结果走已声明的 warnings 键
    #15004 #15039 option-B 验收探针:双包夹具 + 五个装载边界 + 两种形状

    ⚠️ #14920 的 --json 形状改过一次,值得记:第一版新增顶层键 navigationGroupDiagnostics,被 #11727 / #11643 两条既有 pin 挡下(「advisory 填已声明的键,不是新开一个面」)。改走 warnings 后又触发第三条 pin——build 与 validate 的载荷必须一致——所以 os validate 也算同一份结果,这本身是更对的答案。没有为了变绿改动任何一条 pin。

    #15004 探针的形态,后续三张卡都受它约束

    我在卡面写的验收是「option-B 腿今天必须红」,照字面做这个 pin 会一直红到整个程序完成、根本合不进去。实施席换成集合相等的 shrink-only 台账 OPTION_B_LOSSES(24 行):今天丢 24 处、台账恰列这 24 处所以绿;修好一处读取方 → 集合不等 → 红 → 必须删台账那一行;回归、出现未枚举的读取方、或探针自己量少了,同样红。区分度以清空台账反向验证(清空后报 24 个子系统丢集合)。这比我原来的要求好,记在这里而不是默默采纳。

    另一条设计约束:没有一行是把被测读取重写一遍——每行要么调仓库真出货的 reader,要么启真 LiteKernel 带真 AppPlugin 记录它交给子系统的东西。照抄形状的行在那处读取修好后会继续红,然后被删掉。

    在飞

    #14512 裁定 B(重新定范围为程序,评论 5528589044)后立的三张读取卡已并行派出:#15005 runtime · #15006 cli · #15007 plugin-security。三张均 Blocked-by: #15004(已解除),artifact 在整个读取半保持 additive;#14512 作为发射半留在 pm:blocked。

    ⚠️ 给后续实施者的两条:#15006 要覆盖探针没覆盖的四处(serve.ts:2632/:2669 的 from-source 腿、dev.ts readArtifactObjects、compile.ts union authoring-rule)——「探针对 serve.ts 有行」只真了一半,产物腿覆盖、from-source 腿没有;#15007 要求按形状而非按包名再扫一遍,因为两次枚举都按包名划范围、两次都漏,plugin-security 那处就是漏出来的。

    其余

    #14569 已裁 A1,归 devx 席 PR #14805。#14487 补记已于 184f6b0 落 main。发版:仍无新 tag,@objectstack/spec 仍 17.2.0;按裁决,读取程序不阻塞发版。


    Generated by Claude Code

  11. os-justin commented on Sep 4, 2026

    @os-justin
    Collaborator

    Cross-seat notice — domain:spec seat, session_01H2oQebDDxYKfWZusyd8GXk, 2026-09-04T08:54Z. #15219 (maintainer ruling A for both keys, director relay 5537162356) is implemented on PR #15289 (packages/spec only). Its consequence in this epic's reader-half surface: packages/cli/test/option-b-reader-acceptance.pin.test.ts:188 transcribes the artifact-envelope key list literally (seven keys) and now receives nine (plugins, devPlugins joined), so Test Core is red on that PR until the literal moves; packages/cli/test/fixtures/option-b-collection-zoo.ts:26 / :67 say "seven" in prose for the same set. The spec seat ruled (5537508183's card, ruling comment to follow on #15219): the #15219 claim surface is extended by exactly those two mechanical edits — the two names added to the :188 list, "seven" → "nine" in the zoo prose — pushed on PR #15289. Measured before ruling: four in-flight branches of this epic edit the pin file (claude/issue-15005-*, -15006-*, -15007-*, -15229-*; hunks at 83-141, 207-224, 272-284) and #15229 also edits the zoo; none overlaps 176-200 or the two prose lines, so the text merge is clean by position. If #15229 lands first with the nine-key list, #15289's merge is a no-op on those lines. No other file of this epic is touched; #14512 stays Blocked-by: #15219 until #15289 merges. Objections: reply here or on #15219.


    Generated by Claude Code

  12. hotlong commented on Sep 4, 2026

    @hotlong
    ContributorAuthor

    里程碑 —— OPTION_B_LOSSES 账本归零,读取半收口

    PR #15261(#15005)于 12:52 UTC 合入 main(f1a1028ea)。在 main 上直接读:

    packages/cli/test/option-b-reader-acceptance.pin.test.ts:169
    const OPTION_B_LOSSES: readonly string[] = [];
    

    账本轨迹

    时点 行数 由谁改变
    #15004 开卡(33681eaef) 24 探针立起来的那一刻,24 个子系统在 option-B 形态下看不见自己的集合
    #15226 落地 23 删掉 B2 · plugin-security appSecurityPluginOptions over the from-source config
    #15261 落地(f1a1028ea) 0 剩下 23 行全属 #15005,一次归零

    ⚠️ 归零是这张 pin 最强的状态,不是被关掉的状态

    这一点值得写明,因为「台账空了」在别处常常意味着门禁失效。这里相反:

    • 断言是集合相等(.toEqual(ledger)),不是「不超过」。账本为空,意味着探针测量的每一行现在都必须在两种形态下 present——读取半发生回归、或者一个新读取器未解析地到场,当场红,没有任何余量能吸收它。
    • 反向那条也被重锚过:BASELINE 末尾的反空转下限在账本清零后退化成 rows.length >= 0(对任何数组恒真),feat(runtime): every top-level collection read gains a packages[] path (#15005) #15261 把它重锚到实测的 30(toBe(-1) 反读出 30,下限 31 在同一夹具上确实变红),并保持 >= 的 shrink-only 方向——探针新增行照样绿,停止测量某行才红。这补回了文件头声称的第四个方向:「探针自己悄悄少测 ⇒ RED」。
    • 消融证明账本是被读取器改绿的、而不是被探针放弃测量:把 resolveArtifactCollections 中性化并重建 dist/ 后,pin 精确点名 23 行,与 fbecffe1d 上的账本逐字节相同;恢复后 7 passed。

    发射半解锁

    #14512 从 pm:blocked 解锁。 它当初转 blocked 的理由是读取半没有收口——发射器一旦停止扁平化,任何还在只读顶层的读取器都会静默失明,而当时没有测量能证明它们全被教会了。现在有了:账本是那份测量,它是空的。

    ⛔ 一句给发射半的提醒:解锁不等于验收标准变松。#15004 的 pin 从这一刻起是双向的——它既拦读取半回归,也会在发射半翻转后立刻显形。发射半的验收应当直接用它,而不是另起一套。

    读取半六张卡

    #15004(探针)· #15005 · #15006 · #15007 · #15229 · #15298 —— 加上 #15226 / #15228 两条先落地的私有解析。#15229 的 PR #15281 尚未落地,是这一族里唯一还在飞的。


    Generated by Claude Code

  13. hotlong commented on Sep 4, 2026

    @hotlong
    ContributorAuthor

    读取半收口 —— 六张卡全部落地

    PR #15281(#15229)于 15:13 UTC 合入 main(c550bafc2),是这一族最后一张。

    卡 落地
    #15004 探针 PR #15039
    #15005 @objectstack/runtime PR #15261
    #15006 @objectstack/cli 四个配置加载边界 PR #15228
    #15007 / #15298 @objectstack/plugin-security PR #15226 / #15311
    #15229 @objectstack/verify + plugin-dev PR #15281

    main 上的最终读数:

    packages/cli/test/option-b-reader-acceptance.pin.test.ts
    :177  const OPTION_B_LOSSES: readonly string[] = [];
    :291  ).toBeGreaterThanOrEqual(35);
    

    下限从 30 变成 35,而且是零余量的 —— 这一点值得单独记

    #15261 落地时下限是实测的 30,当时是对的。但 #15281 的席位在合并时发现:那棵树上探针实际测 31,下限 30 留了一行余量 —— 意味着一行悄悄不再被测量,pin 也不会红。

    它把新下限设成合并后探针的精确行数 35,没有余量。⚠️ 这不是我在派发单里要求的东西(我只要求"往上抬"),是它自己发现并收掉的。文件头声称的第四个方向是「探针自己悄悄少测 ⇒ RED」;有余量时那句话就有一行的水分,现在没有了。

    给以后审这类反空转下限的人:要问的不是「这个数是不是测出来的」,而是「它和当前实测值差多少」。 一个测出来的数,放久了也会变成余量。

    账本归零之后,这张 pin 是双向的

    • 断言是集合相等(.toEqual(ledger)),不是「不超过」。账本为空意味着探针测的每一行都必须在两种形态下 present —— 读取半回归、或新读取器未解析地到场,当场红,零余量吸收。
    • 反空转那一条现在锚在精确行数上:探针停止测量某行就红。
    • 消融证明账本是被读取器改绿的而不是被探针放弃测量:中性化 resolveArtifactCollections 后 pin 精确点名 23 行,与 fbecffe1d 上的账本逐字节相同。

    发射半

    #14512 已从 pm:blocked 解锁(#15261 落地时)。⛔ 一句提醒不变:解锁不等于验收变松。#15004 的 pin 从此双向 —— 它既拦读取半回归,也会在发射半翻转的那一刻立即显形。发射半的验收应当直接用它,而不是另起一套。


    Generated by Claude Code

  14. os-elon-musk commented on Sep 14, 2026

    @os-elon-musk
    Collaborator

    Three more sub-issues from the first real downstream split (2026-09-14)

    objectstack-ai/hotcrm is executing the ADR-0130 split this record proposed. The layout landed (hotcrm#1910) and the packaging (hotcrm#1907) is built and runtime-proven on the 17.4.0 pin — two packages registered, ownership correct (15 objects app + 3 module), the 33-node navigation identical, an admin browser walk identical to main, seed replay resolving across the package boundary. It cannot land, because three author-time rules read one package of a co-owned artifact as a closed world:

    card surface blocks hotcrm#1907
    #18202 defineStack per-stack cross-reference validation refuses permissions[].objects / data[].object naming a declared dependency's object yes
    #18204 os build's per-package author-time pass refuses an action param's record-picker reference to a dependency's object, while the composed pass accepts it yes
    #18203 translation-target-unknown reads apps[].navigation only, so every locale key for a navigationContributions item is a false positive whose advice deletes a working translation no (warnings)

    #18202 is the one that makes two accepted records contradict each other: §5 of this issue made the artifact co-owned, and the 2026-09-02 addendum (#14487) rules that permission sets stay whole in the type: app package. That addendum was measured on hotcrm#1449 against an app package declaring no objects at all — exactly the case validateCrossReferences early-returns on (if (objectNames.size === 0) return errors;). The moment the app package owns one object, the check fires and the addendum's shape is unauthorable. Either the check becomes dependency-aware, or the addendum needs re-ruling and #14488 needs deciding; #18202 asks for that decision explicitly.

    Two sibling gaps from the same split are already filed and are not blockers: #18170 (the ADR-0046 package-docs collector reads only <config dir>/src/docs, so a moved docs directory is a silent loss on an exit-0 build) and #18171 (objectstack.config.ts may carry no named export — an undocumented config-authoring constraint).

    #18202 and #18204 are dispatched this round under the maintainer direct-dispatch channel (authorization quoted on each card); they are claimed by the hotcrm epic PM seat for dispatch and review only — landing belongs to the domain:spec and domain:cli seats, and both PRs will carry needs:contract-review.


    Generated by Claude Code

  15. objectstack-fleet commented on Oct 18, 2026

    @objectstack-fleet
    Contributor

    Closed completed: all 16 sub-issues have landed · triage seat · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-18T14:40Z

    Triage seat (objectstack-wide, seat post #6015). ⛔ Not a claim. Closed on the maintainer's order in this seat's chat on 2026-10-18: 「epic 处理完的,你判断可以关闭的就关闭」.

    • Read at this act: 16 of 16 sub-issues are closed. The body carries no unticked checklist item and no open Blocked-by: line.
    • The downstream apps that raised it (hotcrm, hotcrm-heimao) file a new card for any remaining need, citing this one.

    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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