Skip to content

feat(runtime): GET /packages 与 GET /packages/:id 每行携带服务端自己的可写判定 writable(isWritablePackage),Studio 不再用 scope 启发式反推 #14375

Description

@hotlong

Part of #14122 · ADR-0130 Consequences 表第 6 行(Studio 按包分组)的服务端半边
Blocks: objectstack-ai/objectui#7177

业务需求

多包物(ADR-0130,#14240 装载路径 + #14354 安装闸已落地)把一个产品拆成 type: app + 若干 type: module 子包。Studio 的包选择器要正确显示每个子包能不能在里面写元数据——这是 Studio 决定给作者哪一套控件(可写 / 🔒 只读 + "duplicate into a writable base")的唯一依据。今天这个判定在客户端用 scope 反推,而服务端的权威规则根本不看 scope 的这一维,两边在多包物上必然分叉。

实测:两条规则不是同一条

服务端权威——isWritablePackage(engine, id)(packages/metadata-protocol/src/package-writability.ts:73,ADR-0070 D2;requireWritablePackage 与 SysMetadataRepository.assertAllowed 都引用它,#8146 维护者裁定"一个可写答案、徽标说真话"的落点):

if (e?.manifests?.has?.(packageId)) return false;          // ① 启动时经 registerApp 注册的代码包 → 只读(不看 scope)
const scope = e?.registry?.getPackage?.(packageId)?.manifest?.scope;
if (typeof scope === 'string' && READ_ONLY_PACKAGE_SCOPES.includes(scope)) return false; // ② system / cloud → 只读
return true;                                              // ③ 其余(project 或缺省 scope 的 DB base)→ 可写

客户端启发式——objectui packages/app-shell/src/views/studio-design/packages-io.ts:38-42:

const scope = typeof m.scope === 'string' ? m.scope : '';
if (scope === 'system' || scope === 'cloud') continue;
out.push({ id, name, writable: scope !== 'project', namespace });

分叉点在 ①:服务端判"代码包"的信号是 engine.manifests(ObjectQL.registerApp → engine.ts:4784 this.manifests.set(id, manifest);#14240 的装载路径对物内每个子包都调 registerApp,所以每个子包都进这张表),客户端没有这张表,只能拿 scope 猜。

链条(每环已对 origin/main 核实):

环 事实 位置
a ManifestSchema.scope 默认 'project' 只在 parse 时施加 spec/src/kernel/manifest.zod.ts:263
b 装载路径刻意把原始 body 交给 registerApp(D7 逐位相同) objectql/src/artifact-packages.ts 模块头
c installPackage 原样存 InstalledPackage.manifest objectql/src/registry.ts
d GET /packages 直接返回 registry.getAllPackages(),只有 ?status= / ?type= 过滤,没有可写判定 runtime/src/domains/packages.ts:269-277
e 一个省略 scope 键的 module 子包(常态——作者依赖默认值)→ 客户端 scope='' → writable: true;服务端 ① → 只读 —

结果:Studio 给一个服务端必拒的包画上"可写"徽标,作者写到 WRITABLE_PACKAGE_REQUIRED 422 才知道。这正是 #8146 裁定要消灭的形状("两面回答同一个问题不一致,其中一面在说谎"),方向相反而已。

⛔ 客户端不能自己修。 直觉修法"缺省 scope 视为 project → 只读"会把 Studio 自建的 DB base 一并翻成只读:它们同样缺省 scope(服务端 ③ 判可写),客户端靠 scope === '' 才把它们显示为可写。区分二者的唯一信号 engine.manifests 只在服务端。所以修法落在平台。

方案

GET /packages 与 GET /packages/:id 的每个包记录附加一个字段:

writable: isWritablePackage(qlService, pkg.manifest?.id ?? pkg.id)
  • 用同一个导出函数,不重拼规则(与 requireWritablePackage 同源,packages.ts:184 已 import)。
  • 纯加法:在响应组装处 spread 一份带 writable 的副本,⛔ 不改写 registry 里的 InstalledPackage 对象,⛔ 不改 isWritablePackage 自身,⛔ 不动 READ_ONLY_PACKAGE_SCOPES。
  • engine 参数就是现有路由已解析的 qlService(与 requireWritablePackage 的传法一致)。

Pins(runtime 域测试,四态一个不少)

  1. 经 registerApp 注册、scope: 'project' 的代码包 → writable: false
  2. 经 registerApp 注册、省略 scope 的代码包(模拟多包物 module 子包)→ writable: false ← 本卡的正题
  3. scope: 'system' / 'cloud' → writable: false
  4. 经 installPackage(不经 registerApp)、省略 scope 的 DB base → writable: true ← 保护 Studio 自建 base
  5. 负向:writable 之外,每行其余字节与今日响应逐位相同(不改 manifest、status、installedAt 等既有键)
  6. 消融:把 ① 那一行的 manifests.has 改成恒 false,pin 2 必须红(证明 writable 真的读了服务端的表,不是又一份 scope 启发式)

边界

  • ⛔ 不动 isWritablePackage 的语义;有争议走 ADR-0070。
  • ⛔ 不动消费者市场面(ADR-0019 D2/D3)。
  • ⛔ 不在本卡改 objectui;objectui#7177 消费这个字段(有则用,无则回落今日启发式以兼容旧服务端)。
  • changeset:@objectstack/runtime: patch(响应加法)。Clause-② 预期 NO:接受/拒绝面不动,只多一个只读字段;若 route ledger / 响应形状快照有门,按门的要求更新。

验收

Activity

  1. self-assigned this
    on Sep 2, 2026
  2. hotlong commented on Sep 2, 2026

    @hotlong
    ContributorAuthor

    认领


    Generated by Claude Code

  3. hotlong commented on Sep 2, 2026

    @hotlong
    ContributorAuthor

    实施中:文件面扩到 metadata-protocol 的生产者——原因

    派发的 dev agent 因账户会话额度触顶(05:40 UTC 重置)起步即失败,本卡由 PM 会话(session_01UHvF5hyiZjnCyExFnfQB8m)直接实现,工作树 objectstack-14375,分支不变。

    读实现时发现卡面只写了 runtime 的 handlePackagesRequest 是不够的:GET /api/v1/packages 有两条注册路(packages/client/src/index.ts:1736-1744 早有记录),Studio 走的 REST 门不读 registry:

    门 行的来源 修法
    runtime dispatcher handlePackagesRequest registry.getAllPackages() / getPackage(id) 卡面原计划:withWritableVerdict(qlService, row),list + detail
    REST packages/rest/src/package-routes.ts:664/805 options.protocol.getMetaItems({ type: 'package' }) 的 item,再把 PackageService.list() 的持久行摊在其上 REST 不依赖 metadata-protocol 运行时(刻意,package-routes.ts:41),不能在 REST 里算 → 在生产者 ObjectStackProtocolImplementation.getMetaItems 的 package 分支给 item 加 writable: this.isWritablePackage(id)(protocol.ts:12657 已有的同一谓词封装)

    两处都是 spread 副本,registry 记录不动、不落库。REST 侧只加一条 pin:持久行摊在 registry item 上不能抹掉 writable(摊的顺序是 REST 唯一可能丢字段的地方);持久-only 行(本进程未注册)不带裁定——registry item 是唯一载体,写进 changeset。REST detail 门 DB-first 的行同样不带(改它要动 #11376 钉死的读拒绝顺序,不在本卡)。

    changeset:@objectstack/runtime: patch + @objectstack/metadata-protocol: patch。


    Generated by Claude Code

  4. hotlong commented on Sep 2, 2026

    @hotlong
    ContributorAuthor

    真实启动取证结果(PR #14430 正文已同步)

    PR 已 ready,随 PASS 自审评论 arm auto-merge。


    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

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions