feat(runtime): GET /packagesGET /packages/:id 每行携带服务端自己的可写判定 writableisWritablePackage),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;requireWritablePackageSysMetadataRepository.assertAllowed 都引用它,#8146 维护者裁定"一个可写答案、徽标说真话"的落点):

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

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

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

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

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

事实位置
aManifestSchema.scope 默认 'project'只在 parse 时施加spec/src/kernel/manifest.zod.ts:263
b装载路径刻意把原始 body 交给 registerApp(D7 逐位相同)objectql/src/artifact-packages.ts 模块头
cinstallPackage 原样存 InstalledPackage.manifestobjectql/src/registry.ts
dGET /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 /packagesGET /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 之外,每行其余字节与今日响应逐位相同(不改 manifeststatusinstalledAt 等既有键)
  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 / 响应形状快照有门,按门的要求更新。

验收

Metadata

Metadata

Assignees

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

    feat(runtime): GET /packagesGET /packages/:id 每行携带服务端自己的可写判定 writableisWritablePackage),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;requireWritablePackageSysMetadataRepository.assertAllowed 都引用它,#8146 维护者裁定"一个可写答案、徽标说真话"的落点):

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

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

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

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

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

    事实位置
    aManifestSchema.scope 默认 'project'只在 parse 时施加spec/src/kernel/manifest.zod.ts:263
    b装载路径刻意把原始 body 交给 registerApp(D7 逐位相同)objectql/src/artifact-packages.ts 模块头
    cinstallPackage 原样存 InstalledPackage.manifestobjectql/src/registry.ts
    dGET /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 /packagesGET /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 之外,每行其余字节与今日响应逐位相同(不改 manifeststatusinstalledAt 等既有键)
    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 / 响应形状快照有门,按门的要求更新。

    验收

    Metadata

    Metadata

    Assignees

    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

      feat(runtime): GET /packagesGET /packages/:id 每行携带服务端自己的可写判定 writableisWritablePackage),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;requireWritablePackageSysMetadataRepository.assertAllowed 都引用它,#8146 维护者裁定"一个可写答案、徽标说真话"的落点):

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

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

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

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

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

      事实位置
      aManifestSchema.scope 默认 'project'只在 parse 时施加spec/src/kernel/manifest.zod.ts:263
      b装载路径刻意把原始 body 交给 registerApp(D7 逐位相同)objectql/src/artifact-packages.ts 模块头
      cinstallPackage 原样存 InstalledPackage.manifestobjectql/src/registry.ts
      dGET /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 /packagesGET /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 之外,每行其余字节与今日响应逐位相同(不改 manifeststatusinstalledAt 等既有键)
      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 / 响应形状快照有门,按门的要求更新。

      验收

      Metadata

      Metadata

      Assignees

      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

        feat(runtime): GET /packagesGET /packages/:id 每行携带服务端自己的可写判定 writableisWritablePackage),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;requireWritablePackageSysMetadataRepository.assertAllowed 都引用它,#8146 维护者裁定"一个可写答案、徽标说真话"的落点):

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

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

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

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

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

        事实位置
        aManifestSchema.scope 默认 'project'只在 parse 时施加spec/src/kernel/manifest.zod.ts:263
        b装载路径刻意把原始 body 交给 registerApp(D7 逐位相同)objectql/src/artifact-packages.ts 模块头
        cinstallPackage 原样存 InstalledPackage.manifestobjectql/src/registry.ts
        dGET /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 /packagesGET /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 之外,每行其余字节与今日响应逐位相同(不改 manifeststatusinstalledAt 等既有键)
        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 / 响应形状快照有门,按门的要求更新。

        验收

        Metadata

        Metadata

        Assignees

        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

          feat(runtime): GET /packagesGET /packages/:id 每行携带服务端自己的可写判定 writableisWritablePackage),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;requireWritablePackageSysMetadataRepository.assertAllowed 都引用它,#8146 维护者裁定"一个可写答案、徽标说真话"的落点):

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

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

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

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

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

          事实位置
          aManifestSchema.scope 默认 'project'只在 parse 时施加spec/src/kernel/manifest.zod.ts:263
          b装载路径刻意把原始 body 交给 registerApp(D7 逐位相同)objectql/src/artifact-packages.ts 模块头
          cinstallPackage 原样存 InstalledPackage.manifestobjectql/src/registry.ts
          dGET /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 /packagesGET /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 之外,每行其余字节与今日响应逐位相同(不改 manifeststatusinstalledAt 等既有键)
          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 / 响应形状快照有门,按门的要求更新。

          验收

          Metadata

          Metadata

          Assignees

          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

            feat(runtime): GET /packagesGET /packages/:id 每行携带服务端自己的可写判定 writableisWritablePackage),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;requireWritablePackageSysMetadataRepository.assertAllowed 都引用它,#8146 维护者裁定"一个可写答案、徽标说真话"的落点):

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

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

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

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

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

            事实位置
            aManifestSchema.scope 默认 'project'只在 parse 时施加spec/src/kernel/manifest.zod.ts:263
            b装载路径刻意把原始 body 交给 registerApp(D7 逐位相同)objectql/src/artifact-packages.ts 模块头
            cinstallPackage 原样存 InstalledPackage.manifestobjectql/src/registry.ts
            dGET /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 /packagesGET /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 之外,每行其余字节与今日响应逐位相同(不改 manifeststatusinstalledAt 等既有键)
            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 / 响应形状快照有门,按门的要求更新。

            验收

            Metadata

            Metadata

            Assignees

            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

              feat(runtime): GET /packagesGET /packages/:id 每行携带服务端自己的可写判定 writableisWritablePackage),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;requireWritablePackageSysMetadataRepository.assertAllowed 都引用它,#8146 维护者裁定"一个可写答案、徽标说真话"的落点):

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

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

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

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

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

              事实位置
              aManifestSchema.scope 默认 'project'只在 parse 时施加spec/src/kernel/manifest.zod.ts:263
              b装载路径刻意把原始 body 交给 registerApp(D7 逐位相同)objectql/src/artifact-packages.ts 模块头
              cinstallPackage 原样存 InstalledPackage.manifestobjectql/src/registry.ts
              dGET /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 /packagesGET /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 之外,每行其余字节与今日响应逐位相同(不改 manifeststatusinstalledAt 等既有键)
              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 / 响应形状快照有门,按门的要求更新。

              验收

              Metadata

              Metadata

              Assignees

              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

                feat(runtime): GET /packagesGET /packages/:id 每行携带服务端自己的可写判定 writableisWritablePackage),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;requireWritablePackageSysMetadataRepository.assertAllowed 都引用它,#8146 维护者裁定"一个可写答案、徽标说真话"的落点):

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

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

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

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

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

                事实位置
                aManifestSchema.scope 默认 'project'只在 parse 时施加spec/src/kernel/manifest.zod.ts:263
                b装载路径刻意把原始 body 交给 registerApp(D7 逐位相同)objectql/src/artifact-packages.ts 模块头
                cinstallPackage 原样存 InstalledPackage.manifestobjectql/src/registry.ts
                dGET /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 /packagesGET /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 之外,每行其余字节与今日响应逐位相同(不改 manifeststatusinstalledAt 等既有键)
                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 / 响应形状快照有门,按门的要求更新。

                验收

                Metadata

                Metadata

                Assignees

                Type

                No type

                Projects

                No projects

                  Milestone

                  No milestone

                  Relationships

                  None yet

                  Development

                  No branches or pull requests

                  Issue actions